diff options
author | Calvin Huang <calhuang@google.com> | 2022-03-29 13:26:27 -0700 |
---|---|---|
committer | Calvin Huang <calhuang@google.com> | 2022-04-04 11:25:35 -0700 |
commit | e6263ed070cd17e1988f41f2d712bce06ff0fbb1 (patch) | |
tree | f2dad3fcc8f3ef692285aa08480ba8328e97ccbb | |
parent | 295b2970abbb4ab4babf2cf7abbeace354532428 (diff) | |
download | Templates-e6263ed070cd17e1988f41f2d712bce06ff0fbb1.tar.gz |
Publish AOSP Templates Host v1.0
Fix: 194232491
Test: ./gradlew :app:installDebug and showcase
Change-Id: I9b06dc77ec1ff10ac507013463458299fcc0e698
458 files changed, 46224 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d7af7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +Host/build/** +Host/.gradle/** +Host/.idea/** +Host/app/build/** +Host/app/apphost/build/** +Host/app/renderer/build/** + diff --git a/Host/app/apphost/build.gradle b/Host/app/apphost/build.gradle new file mode 100644 index 0000000..26bb402 --- /dev/null +++ b/Host/app/apphost/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'com.android.library' +} + +android { + compileSdk 31 + + defaultConfig { + minSdk 29 + targetSdk 31 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation "androidx.car.app:app:1.2.0-beta02" + compileOnly 'com.google.auto.value:auto-value-annotations:1.9' + annotationProcessor 'com.google.auto.value:auto-value:1.9' + implementation group: 'com.google.errorprone', name: 'error_prone_annotations', version: '2.11.0' + implementation group: 'org.checkerframework', name: 'checker-qual', version: '3.21.1' + implementation('com.google.guava:guava:31.0.1-jre') + implementation 'com.github.bumptech.glide:glide:4.12.0' + annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'com.google.android.material:material:1.5.0' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:4.3.1' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' +} diff --git a/Host/app/apphost/consumer-rules.pro b/Host/app/apphost/consumer-rules.pro new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/Host/app/apphost/consumer-rules.pro diff --git a/Host/app/apphost/proguard-rules.pro b/Host/app/apphost/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Host/app/apphost/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile
\ No newline at end of file diff --git a/Host/app/apphost/src/main/AndroidManifest.xml b/Host/app/apphost/src/main/AndroidManifest.xml new file mode 100644 index 0000000..97e8f2b --- /dev/null +++ b/Host/app/apphost/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<manifest package="com.android.car.libraries.apphost" + xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- Declares permissions meant for the Car App Library (3p apps) --> + <!-- Permission that apps can use to get access to a canvas surface. --> + <permission + android:name="androidx.car.app.ACCESS_SURFACE" + android:protectionLevel="normal"/> + <!-- Permission that apps can use to get access to the navigation templates. --> + <permission + android:name="androidx.car.app.NAVIGATION_TEMPLATES" + android:protectionLevel="normal"/> + <!-- Permission that apps can use to get access to templates that show a map. --> + <permission + android:name="androidx.car.app.MAP_TEMPLATES" + android:protectionLevel="normal"/> +</manifest> 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); +} diff --git a/Host/app/build.gradle b/Host/app/build.gradle new file mode 100644 index 0000000..139ef01 --- /dev/null +++ b/Host/app/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.android.application' + id 'dagger.hilt.android.plugin' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' + +} + +android { + compileSdk 31 + + defaultConfig { + applicationId "com.android.car.templates.host" + minSdk 29 + targetSdk 31 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + res.srcDirs = [ + 'src/main/java/com/android/car/templates/host/res', + 'src/main/java/com/android/car/templates/host/view/widgets/maps/res', + + 'src/main/java/com/android/car/templates/host/di/res', + ] + } + } +} + +dependencies { + implementation "androidx.car.app:app:1.2.0-beta02" + implementation 'com.google.guava:guava:30.1.1-jre' + + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'com.google.android.material:material:1.5.0' + implementation project(path: ':app:renderer') + implementation project(path: ':app:apphost') + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0") + + + implementation "com.google.dagger:hilt-android:2.40.5" + kapt 'com.google.dagger:hilt-compiler:2.40.5' + runtimeOnly 'androidx.asynclayoutinflater:asynclayoutinflater:1.0.0' + +} diff --git a/Host/app/proguard-rules.pro b/Host/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Host/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile
\ No newline at end of file diff --git a/Host/app/renderer/build.gradle b/Host/app/renderer/build.gradle new file mode 100644 index 0000000..d4871bc --- /dev/null +++ b/Host/app/renderer/build.gradle @@ -0,0 +1,86 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id "com.google.protobuf" + +} + + + +android { + compileSdk 31 + + defaultConfig { + minSdk 29 + targetSdk 31 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + res.srcDirs = [ + 'src/main/java/com/android/car/libraries/templates/host/internal/res', + 'src/main/java/com/android/car/libraries/templates/host/internal/debug/res', + 'src/main/java/com/android/car/libraries/templates/host/internal/debug/styles/res', + 'src/main/java/com/android/car/libraries/templates/host/overlayable/res', + 'src/main/java/com/android/car/libraries/templates/host/view/res', + 'src/main/java/com/android/car/libraries/templates/host/view/common/res', + 'src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res', + 'src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res', + 'src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res', + 'src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res', + 'src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res', + + ] + } + } + +} + +dependencies { + implementation "androidx.car.app:app:1.2.0-beta02" + implementation "androidx.car.app:app-automotive:1.2.0-beta02" + implementation 'com.google.guava:guava:30.1.1-jre' + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'com.android.car.ui:car-ui-lib:2.0.0' + implementation 'androidx.appcompat:appcompat:1.4.1' + implementation 'com.google.android.material:material:1.5.0' + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0") + + implementation "androidx.recyclerview:recyclerview:1.2.1" + // For control over item selection of both touch and mouse driven selection + implementation "androidx.recyclerview:recyclerview-selection:1.1.0" + implementation 'com.google.zxing:core:3.4.1' + + + implementation "com.google.dagger:hilt-android:2.40.5" + kapt 'com.google.dagger:hilt-compiler:2.40.5' + + implementation files('./libs/libnavigation_state_kt_proto_lite.ktproto.jar') + implementation files('./libs/libnavigation_state_proto-lite.jar') + implementation 'com.google.protobuf:protobuf-kotlin:3.19.4' + + implementation project(path: ':app:apphost') + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + + implementation files(gradle.ext.lib_car_system_stubs) + +} diff --git a/Host/app/renderer/consumer-rules.pro b/Host/app/renderer/consumer-rules.pro new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/Host/app/renderer/consumer-rules.pro diff --git a/Host/app/renderer/libs/libnavigation_state_kt_proto_lite.ktproto.jar b/Host/app/renderer/libs/libnavigation_state_kt_proto_lite.ktproto.jar Binary files differnew file mode 100644 index 0000000..a6163b6 --- /dev/null +++ b/Host/app/renderer/libs/libnavigation_state_kt_proto_lite.ktproto.jar diff --git a/Host/app/renderer/libs/libnavigation_state_proto-lite.jar b/Host/app/renderer/libs/libnavigation_state_proto-lite.jar Binary files differnew file mode 100644 index 0000000..ae4807f --- /dev/null +++ b/Host/app/renderer/libs/libnavigation_state_proto-lite.jar diff --git a/Host/app/renderer/proguard-rules.pro b/Host/app/renderer/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Host/app/renderer/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile
\ No newline at end of file diff --git a/Host/app/renderer/src/main/AndroidManifest.xml b/Host/app/renderer/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a1258a4 --- /dev/null +++ b/Host/app/renderer/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.car.libraries.templates.host"> + + <application> + <activity + android:name=".internal.debug.ClusterActivity" + android:allowEmbedded="true" + android:excludeFromRecents="true" + android:exported="true" + android:launchMode="singleInstance" + android:process=":renderer_service" + android:resizeableActivity="true" + android:screenOrientation="user" + android:theme="@style/Theme.Template"> + <!-- In car_embedded builds, indicate that we are distraction optimized to prevent maps + from being killed when the car is moving. --> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.car.cluster.NAVIGATION" /> + </intent-filter> + + <meta-data + android:name="distractionOptimized" + android:value="true" /> + </activity> + </application> + +</manifest> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/BootCompleteReceiver.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/BootCompleteReceiver.kt new file mode 100644 index 0000000..1df079e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/BootCompleteReceiver.kt @@ -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.templates.host + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.android.car.libraries.apphost.logging.L +import com.android.car.libraries.apphost.logging.LogTags + +/** A [BroadcastReceiver] used to pre-warm the host and set it as a foreground service. */ +class BootCompleteReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + L.d(LogTags.SERVICE) { "StartUpBootReceiver: received ${intent.action}" } + val serviceIntent = Intent(context, RendererService::class.java) + context.startForegroundService(serviceIntent) + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/RendererService.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/RendererService.kt new file mode 100644 index 0000000..61c3935 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/RendererService.kt @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION +import android.content.res.Configuration +import android.os.Binder +import android.os.IBinder +import android.view.LayoutInflater +import androidx.car.app.HandshakeInfo +import androidx.car.app.activity.renderer.ICarAppActivity +import androidx.car.app.activity.renderer.IRendererService +import androidx.car.app.serialization.Bundleable +import androidx.car.app.versioning.CarAppApiLevels +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.android.car.libraries.apphost.common.HostResourceIds +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.StatusReporter +import com.android.car.libraries.apphost.view.TemplateConverterRegistry +import com.android.car.libraries.apphost.view.TemplatePresenterRegistry +import com.android.car.libraries.templates.host.di.FeaturesConfig +import com.android.car.libraries.templates.host.di.HostApiLevelConfig +import com.android.car.libraries.templates.host.di.TelemetryHandlerFactory +import com.android.car.libraries.templates.host.di.ThemeManager +import com.android.car.libraries.templates.host.di.UxreConfig +import com.android.car.libraries.templates.host.internal.CarActivityDispatcher +import com.android.car.libraries.templates.host.internal.CommonUtils +import com.android.car.libraries.templates.host.internal.LogUtil +import com.android.car.libraries.templates.host.internal.StatusManager +import com.android.car.libraries.templates.host.internal.debug.ClusterActivity +import com.android.car.libraries.templates.host.renderer.ScreenRenderer +import com.android.car.libraries.templates.host.renderer.ScreenRendererRepository +import com.android.car.libraries.templates.host.view.presenters.common.CommonTemplateConverter +import com.android.car.libraries.templates.host.view.presenters.common.CommonTemplatePresenterFactory +import com.android.car.libraries.templates.host.view.presenters.maps.MapsTemplatePresenterFactory +import com.android.car.libraries.templates.host.view.presenters.navigation.NavigationTemplatePresenterFactory +import com.android.car.ui.CarUiLayoutInflaterFactory +import dagger.hilt.android.AndroidEntryPoint +import java.io.FileDescriptor +import java.io.PrintWriter +import javax.inject.Inject + +/** A service used to render content of a car app service inside a car app activity. */ +@AndroidEntryPoint +class RendererService : Service() { + // TODO(b/182486338): Migrate the inject point to TemplateView + @Inject lateinit var mapsTemplatePresenterFactory: MapsTemplatePresenterFactory + + @Inject lateinit var hostResourceIds: HostResourceIds + + @Inject lateinit var uxreConfig: UxreConfig + + @Inject lateinit var hostApiLevelConfig: HostApiLevelConfig + + @Inject lateinit var themeManager: ThemeManager + + @Inject lateinit var telemetryHandlerFactory: TelemetryHandlerFactory + @Inject lateinit var hostFeaturesConfig: FeaturesConfig + + /** Whether the debug overlay is active. */ + private var isDebugOverlayActive = false + + override fun onCreate() { + super.onCreate() + L.d(LogTags.SERVICE) { "RendererService.onCreate" } + + // This must be executed within ANR timeout (5 seconds) of the host being launched. + setAsForeground() + LogUtil.init(telemetryHandlerFactory, applicationContext) + + val layoutInflater = LayoutInflater.from(this.applicationContext) + if (layoutInflater.factory2 == null) { + layoutInflater.factory2 = CarUiLayoutInflaterFactory() + } + + // preload some MapView rendering code to speed things up before it is actually used + // by PlaceListMapTemplatePresenter. + ThreadUtils.enqueueOnMain { mapsTemplatePresenterFactory.preloadMapView(this) } + initClusterActivity() + } + + private fun initClusterActivity() { + val state = + if (hostFeaturesConfig.isClusterActivityEnabled()) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + packageManager.setComponentEnabledSetting( + ComponentName(this, ClusterActivity::class.java), + state, + PackageManager.DONT_KILL_APP + ) + } + + private fun setAsForeground() { + val channel = + NotificationChannel( + CHANNEL_ID, + application.applicationInfo.name, + NotificationManager.IMPORTANCE_NONE + ) + val notificationManager = NotificationManagerCompat.from(this) + notificationManager.createNotificationChannel(channel) + + val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) + val notification = + notificationBuilder + .setOngoing(true) + .setSmallIcon(application.applicationInfo.icon) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(Notification.CATEGORY_SERVICE) + .build() + + startForeground( + FOREGROUND_SERVICE_NOTIFICATION_ID, + notification, + FOREGROUND_SERVICE_TYPE_LOCATION + ) + } + + override fun onBind(intent: Intent): IBinder { + registerPresenters() + return RendererServiceBinder(this) + } + + override fun onUnbind(intent: Intent): Boolean { + L.d(LogTags.SERVICE) { "RendererService.onUnbind" } + + // Note that even when the RendererService is unbound. The CarAppService remains bound + // because the car app can remain alive in the background (e.g. nav apps sending TBT + // instructions). Hence we do not clear the Carhosts here. + ScreenRendererRepository.clear() + return super.onUnbind(intent) + } + + override fun onDestroy() { + L.d(LogTags.SERVICE) { "RendererService.onDestroy" } + + // Note that even when the RendererService is unbound. The CarAppService remains bound + // because the car app can remain alive in the background (e.g. nav apps sending TBT + // instructions). Hence we do not clear the Carhosts here. + ScreenRendererRepository.clear() + super.onDestroy() + } + + override fun dump(fd: FileDescriptor?, writer: PrintWriter?, args: Array<out String>?) { + if (args?.contains("debug_overlay") == true) { + if (!CommonUtils.isDebugEnabled(/* context= */ this)) { + writer?.println("Debug enabled required for debug overlay") + return + } + isDebugOverlayActive = !isDebugOverlayActive + ScreenRendererRepository.getAll().forEach { it.showDebugOverlay(isDebugOverlayActive) } + if (isDebugOverlayActive) { + writer?.println("Debug overlay enabled") + } else { + writer?.println("Debug overlay disabled") + } + } else { + writer?.let { StatusManager.reportStatus(writer, StatusReporter.Pii.HIDE) } + } + } + + override fun onConfigurationChanged(config: Configuration) { + super.onConfigurationChanged(config) + + ScreenRendererRepository.getAll().forEach { it.onConfigurationChanged(config) } + } + + private fun registerPresenters() { + TemplatePresenterRegistry.get().clear() + TemplatePresenterRegistry.get().register(NavigationTemplatePresenterFactory.get()) + TemplatePresenterRegistry.get().register(CommonTemplatePresenterFactory.get()) + TemplateConverterRegistry.get().register(CommonTemplateConverter.get()) + TemplatePresenterRegistry.get().register(mapsTemplatePresenterFactory) + } + + private inner class RendererServiceBinder(val context: Context) : + IRendererService.Stub(), CarActivityDispatcher.Callback { + + override fun initialize( + carActivity: ICarAppActivity, + serviceName: ComponentName, + displayId: Int + ): Boolean { + L.d(LogTags.SERVICE) { "RendererServiceBinder.initialize: $serviceName" } + val renderer = findRenderer(serviceName, displayId) ?: return false + ThreadUtils.runOnMain { renderer.onCreateActivity(carActivity) } + return true + } + + override fun terminate(serviceName: ComponentName) { + if (!isValid(serviceName)) return + L.d(LogTags.SERVICE) { "RendererServiceBinder.terminate: $serviceName" } + doTerminate(serviceName) + } + + override fun onDisconnect(serviceName: ComponentName) { + L.d(LogTags.SERVICE) { "RendererServiceBinder.onDisconnect: $serviceName" } + doTerminate(serviceName) + } + + private fun doTerminate(serviceName: ComponentName) { + ThreadUtils.runOnMain { ScreenRendererRepository.remove(serviceName)?.onDestroy() } + } + + override fun onNewIntent(intent: Intent, serviceName: ComponentName, displayId: Int): Boolean { + L.i(LogTags.SERVICE) { "RendererServiceBinder.onNewIntent: $serviceName" } + val renderer = findRenderer(serviceName, displayId) ?: return false + ThreadUtils.runOnMain { renderer.onNewIntent(intent) } + return true + } + + override fun performHandshake(serviceName: ComponentName, appLatestApiLevel: Int): Bundleable { + val apiLevel = Math.min(appLatestApiLevel, CarAppApiLevels.getLatest()) + L.i(LogTags.SERVICE) { + "RendererServiceBinder.performHandshake: $serviceName, " + + "appLatestApiLevel: $appLatestApiLevel, chosen api level: $apiLevel" + } + // Store in the host whenever we need to start checking versions. + return Bundleable.create(HandshakeInfo(context.packageName, apiLevel)) + } + + private fun findRenderer(serviceName: ComponentName, displayId: Int): ScreenRenderer? { + if (!isValid(serviceName)) return null + + return ScreenRendererRepository.computeIfAbsent(serviceName) { + L.d(LogTags.SERVICE) { + "RendererServiceBinder.findRenderer: $serviceName - " + "created new ScreenRenderer" + } + ScreenRenderer( + context.applicationContext, + serviceName, + displayId, + this, + hostResourceIds, + uxreConfig, + hostApiLevelConfig, + themeManager, + telemetryHandlerFactory.create(context, serviceName), + hostFeaturesConfig, + isDebugOverlayActive + ) + } + } + + private fun isValid(serviceName: ComponentName?): Boolean { + if (serviceName == null) { + L.e(LogTags.SERVICE) { "Service name was not specified!" } + return false + } + val senderPackage = context.packageManager.getNameForUid(Binder.getCallingUid()) + if (senderPackage == null || senderPackage != serviceName.packageName) { + L.e(LogTags.SERVICE) { "Could not verify the caller!" } + return false + } + return true + } + } + + companion object { + const val CHANNEL_ID = "default" + const val FOREGROUND_SERVICE_NOTIFICATION_ID = 1 + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/FeaturesConfig.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/FeaturesConfig.java new file mode 100644 index 0000000..5bb5d9a --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/FeaturesConfig.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.templates.host.di; +/** An interface for providing host features config */ +public interface FeaturesConfig { + /** Returns whether the host has cluster activity features enabled */ + boolean isClusterActivityEnabled(); + + /** Returns whether the host supports pan and zoom features in the navigation template */ + boolean isNavPanZoomEnabled(); + + /** Returns whether the host supports pan and zoom features in POI and route preview templates */ + boolean isPoiRoutePreviewPanZoomEnabled(); + + /** Returns whether the host supports content refresh on POI templates */ + boolean isPoiContentRefreshEnabled(); +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/HostApiLevelConfig.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/HostApiLevelConfig.java new file mode 100644 index 0000000..80aea9d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/HostApiLevelConfig.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.templates.host.di; + +import android.content.ComponentName; + +/** An interface for providing host API overrides */ +public interface HostApiLevelConfig { + /** The min api level for given car app */ + int getHostMinApiLevel(int defaultValue, ComponentName componentName); + + /** The max api level for given car app */ + int getHostMaxApiLevel(int defaultValue, ComponentName componentName); +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/MapViewContainerFactory.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/MapViewContainerFactory.java new file mode 100644 index 0000000..84f5c91 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/MapViewContainerFactory.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.di; + +import android.content.Context; +import com.android.car.libraries.apphost.view.widget.map.AbstractMapViewContainer; + +/** An interface used for creating a {@link AbstractMapViewContainer} */ +public interface MapViewContainerFactory { + + /** returns a AbstractMapViewContainer that used in {@link AbstractMapViewContainer} */ + AbstractMapViewContainer create(Context context, int theme); +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/TelemetryHandlerFactory.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/TelemetryHandlerFactory.java new file mode 100644 index 0000000..f52df92 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/TelemetryHandlerFactory.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.templates.host.di; + +import android.content.ComponentName; +import android.content.Context; +import com.android.car.libraries.apphost.logging.TelemetryHandler; + +/** An interface used for creating a {@link TelemetryHandler} */ +public interface TelemetryHandlerFactory { + + /** Returns a new {@link TelemetryHandler} instance */ + TelemetryHandler create(Context context, ComponentName componentName); +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/ThemeManager.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/ThemeManager.java new file mode 100644 index 0000000..beca618 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/ThemeManager.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.templates.host.di; + +import android.content.Context; + +/** Provides the theme that should be used throughout the UI. */ +public interface ThemeManager { + + /** Applies appropriate theme to the context. */ + void applyTheme(Context context); +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/UxreConfig.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/UxreConfig.java new file mode 100644 index 0000000..5c86c4c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/di/UxreConfig.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.templates.host.di; + +/** An interface used for providing UXRE configs */ +public interface UxreConfig { + + /** The max size of a car app template stack */ + int getTemplateStackMaxSize(int defaultValue); + + /** The max length of a car app list for showing routes. */ + int getRouteListMaxLength(int defaultValue); + + /** The max length of a car app list for showing pane information. */ + int getPaneMaxLength(int defaultValue); + + /** The max length of a car app grid view. */ + int getGridMaxLength(int defaultValue); + + /** + * The max length of a generic, uniform car app list for cases where the OEM did not override the + * default UXRE cumulative content limit value. + */ + int getListMaxLength(int defaultValue); + + /** Default max string length */ + int getCarAppDefaultMaxStringLength(int defaultValue); +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/AppIconLoaderImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/AppIconLoaderImpl.kt new file mode 100644 index 0000000..8ba55af --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/AppIconLoaderImpl.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.content.ComponentName +import android.content.Context +import android.graphics.drawable.Drawable +import com.android.car.libraries.apphost.common.AppIconLoader + +/** Android Automotive implementation of [AppIconLoader] */ +object AppIconLoaderImpl : AppIconLoader { + override fun getRoundAppIcon(context: Context, componentName: ComponentName): Drawable { + return try { + val pm = context.packageManager + val applicationInfo = pm.getApplicationInfo(componentName.packageName, 0) + val appIconResId = applicationInfo.icon + + pm.getResourcesForApplication(componentName.packageName).getDrawable(appIconResId, null) + } catch (ex: Exception) { + getDefaultAppIcon(context) + } + } + + private fun getDefaultAppIcon(context: Context): Drawable { + return context.resources.getDrawable(android.R.drawable.sym_def_app_icon, null) + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarActivityDispatcher.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarActivityDispatcher.java new file mode 100644 index 0000000..fdff26b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarActivityDispatcher.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal; + +import android.content.ComponentName; +import android.os.DeadObjectException; +import android.os.RemoteException; +import android.util.Log; +import androidx.car.app.activity.renderer.ICarAppActivity; +import com.android.car.libraries.apphost.logging.LogTags; + +/** + * A dispatcher that can be used to send messages to {@link ICarAppActivity}, handling any remote + * errors. + */ +public class CarActivityDispatcher { + private final ComponentName mAppName; + private final ICarAppActivity mCarAppActivity; + private final Callback mCallback; + private boolean mIsConnected; + + public CarActivityDispatcher( + ComponentName appName, ICarAppActivity carActivity, Callback callback) { + mAppName = appName; + mCarAppActivity = carActivity; + mCallback = callback; + mIsConnected = true; + } + + /** {@link CarActivityDispatcher} callbacks */ + public interface Callback { + /** Notifies that the client associated with this {@link ICarAppActivity} is disconnected */ + void onDisconnect(ComponentName appName); + } + + /** An IPC call that can be dispatched by this dispatcher */ + public interface IPCCall { + /** Remote invocation to execute */ + void call(ICarAppActivity carActivity) throws RemoteException; + } + + /** Returns true if the application is still considered to be connected */ + public boolean isConnected() { + return mIsConnected; + } + + /** + * Dispatches an IPC call to the {@link ICarAppActivity} associated with this dispatcher. If this + * result in an error, the dispatcher will handle the error and returns false. + * + * @return true iif dispatch is successful. + */ + public boolean dispatchNoFail(IPCCall call) { + if (!mIsConnected) { + // Ignoring request as we have already disconnected from the client app + return false; + } + + try { + call.call(mCarAppActivity); + return true; + } catch (DeadObjectException e) { + Log.w(LogTags.APP_HOST, "App " + mAppName + " is dead", e); + return false; + } catch (RemoteException e) { + Log.w(LogTags.APP_HOST, "App " + mAppName + " has caused a remote exception", e); + return false; + } catch (Throwable e) { + Log.w(LogTags.APP_HOST, "App " + mAppName + " caused an unknown error", e); + return false; + } + } + + /** + * Dispatches an IPC call to the {@link ICarAppActivity} associated with this dispatcher. If this + * result in an error, the dispatcher will handle the error and then call {@link + * Callback#onDisconnect(ComponentName)} to notify that this client is not longer valid. + */ + public void dispatch(IPCCall call) { + if (!mIsConnected) { + // Ignoring request as we have already disconnected from the client app + return; + } + + try { + call.call(mCarAppActivity); + } catch (DeadObjectException e) { + Log.e(LogTags.APP_HOST, "App " + mAppName + " is dead", e); + mIsConnected = false; + mCallback.onDisconnect(mAppName); + } catch (RemoteException e) { + Log.e(LogTags.APP_HOST, "App " + mAppName + " has caused a remote exception", e); + disconnect(); + } catch (Throwable e) { + Log.e(LogTags.APP_HOST, "App " + mAppName + " caused an unknown error", e); + disconnect(); + } + } + + /** Disconnects this dispatcher from its associated {@link ICarAppActivity} */ + public void disconnect() { + mIsConnected = false; + try { + mCarAppActivity.finishCarApp(); + } catch (Throwable e) { + // Ignoring error as we are already finishing anyways (avoid spamming the logs). + } + mCallback.onDisconnect(mAppName); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarAppServiceInfo.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarAppServiceInfo.java new file mode 100644 index 0000000..6ea5d12 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarAppServiceInfo.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.templates.host.internal; + +import static android.content.pm.PackageManager.GET_RESOLVED_FILTER; +import static androidx.car.app.CarAppService.CATEGORY_NAVIGATION_APP; +import static androidx.car.app.CarAppService.SERVICE_INTERFACE; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; + +/** This class provides information about car app services. */ +public class CarAppServiceInfo { + private final PackageManager mPackageManager; + private final ComponentName mServiceName; + + public CarAppServiceInfo(Context context, ComponentName serviceName) { + mPackageManager = context.getPackageManager(); + mServiceName = serviceName; + } + + /** Returns true for navigation services. */ + public boolean isNavigationService() { + Intent intent = + new Intent(SERVICE_INTERFACE) + .setPackage(mServiceName.getPackageName()) + .addCategory(CATEGORY_NAVIGATION_APP); + return !mPackageManager.queryIntentServices(intent, GET_RESOLVED_FILTER).isEmpty(); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarHostConfigImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarHostConfigImpl.kt new file mode 100644 index 0000000..bca23e9 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarHostConfigImpl.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.annotation.StyleableRes +import androidx.car.app.versioning.CarAppApiLevels +import com.android.car.libraries.apphost.common.CarHostConfig +import com.android.car.libraries.templates.host.R +import com.android.car.libraries.templates.host.di.FeaturesConfig +import com.android.car.libraries.templates.host.di.HostApiLevelConfig + +/** Configuration options from the car host. */ +class CarHostConfigImpl( + private val context: Context, + appName: ComponentName, + hostApiLevelConfig: HostApiLevelConfig, + private val featuresConfig: FeaturesConfig +) : CarHostConfig(appName) { + private val hostMinApi: Int = + hostApiLevelConfig.getHostMinApiLevel(CarAppApiLevels.getOldest(), appName) + private val hostMaxApi: Int = + hostApiLevelConfig.getHostMaxApiLevel(CarAppApiLevels.getLatest(), appName) + + override fun getHostMinApi(): Int { + return hostMinApi + } + + override fun getHostMaxApi(): Int { + return hostMaxApi + } + + override fun isButtonColorOverriddenByOEM(): Boolean { + return getBooleanAttr(R.attr.templateActionButtonUseOemColors) + } + + override fun getAppUnbindSeconds(): Int { + return context.resources.getInteger( + R.integer.app_unbind_delay_seconds + ) + } + + override fun getHostIntentExtrasToRemove(): MutableList<String> { + return mutableListOf() + } + + override fun isNewTaskFlowIntent(intent: Intent?): Boolean { + return true + } + + override fun getPrimaryActionOrder(): Int { + return getIntAttr(R.attr.templateActionButtonPrimaryHorizontalOrder) + } + + override fun isClusterEnabled(): Boolean { + return featuresConfig.isClusterActivityEnabled() + } + + override fun isNavPanZoomEnabled(): Boolean { + return featuresConfig.isNavPanZoomEnabled() + } + + override fun isPoiRoutePreviewPanZoomEnabled(): Boolean { + return featuresConfig.isPoiRoutePreviewPanZoomEnabled() + } + + override fun isPoiContentRefreshEnabled(): Boolean { + return featuresConfig.isPoiContentRefreshEnabled() + } + + private fun getBooleanAttr(attr: Int): Boolean { + @StyleableRes val themeAttrs = intArrayOf(attr) + val ta = context.obtainStyledAttributes(themeAttrs) + val value = ta.getBoolean(0, false) + ta.recycle() + return value + } + + private fun getIntAttr(attr: Int): Int { + @StyleableRes val themeAttrs = intArrayOf(attr) + val ta = context.obtainStyledAttributes(themeAttrs) + val value = ta.getInt(0, 0) + ta.recycle() + return value + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarHostRepository.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarHostRepository.kt new file mode 100644 index 0000000..bc3138f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarHostRepository.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.content.ComponentName +import androidx.annotation.MainThread +import com.android.car.libraries.apphost.CarHost +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.google.common.base.Supplier +import java.io.PrintWriter +import java.util.concurrent.ConcurrentHashMap + +/** Manages a cache of [CarHost]s. */ +@MainThread +object CarHostRepository : StatusReporter { + private val cache: MutableMap<ComponentName, CarHost> = ConcurrentHashMap() + + init { + StatusManager.addStatusReporter(StatusManager.ReportSection.APP_HOST, this) + } + + /** + * Returns a [CarHost] for the given `appName` to use for app communication. If the key is not + * present in the cache, uses the [Supplier] to retrieve the [CarHost] and puts it in the cache. + */ + @Synchronized + fun computeIfAbsent(appName: ComponentName, carHostSupplier: Supplier<CarHost>): CarHost { + return cache.computeIfAbsent(appName) { carHostSupplier.get() } + } + + /** + * @return a [CarHost] for the given [appName] to use for app communication, or `null` if the key + * is not present in the cache. + */ + @Synchronized + fun get(appName: ComponentName): CarHost? { + return cache[appName] + } + + /** Invalidates and removes the [CarHost] from cache for the given [appName]. */ + @Synchronized + fun remove(appName: ComponentName) { + val carHost = cache.remove(appName) + carHost?.unbindFromApp() + carHost?.invalidate() + } + + /** Invalidates all the [CarHost] objects in the cache and empties the cache. */ + @Synchronized + fun clear() { + if (cache.isNotEmpty()) { + for (carHost in cache.values) { + carHost.unbindFromApp() + carHost.invalidate() + } + } + cache.clear() + } + + override fun reportStatus(pw: PrintWriter, piiHandling: StatusReporter.Pii) { + try { + pw.println("Car host cache") + pw.printf("- size: %d\n", cache.size) + pw.printf("- hosts: %d\n", cache.size) + for ((name, value) in cache) { + pw.println("\n-------------------------------") + pw.printf("Host: %s\n", name.flattenToShortString()) + value.reportStatus(pw, piiHandling) + } + } catch (t: Throwable) { + L.e(LogTags.APP_HOST, t, "Failed to produce status report for car host cache") + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarUxRestrictionsUtil.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarUxRestrictionsUtil.java new file mode 100644 index 0000000..2441bb5 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CarUxRestrictionsUtil.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal; + +import android.car.Car; +import android.car.drivingstate.CarUxRestrictions; +import android.car.drivingstate.CarUxRestrictions.CarUxRestrictionsInfo; +import android.car.drivingstate.CarUxRestrictionsManager; +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.android.car.libraries.apphost.common.ThreadUtils; +import java.util.Collections; +import java.util.Set; +import java.util.WeakHashMap; + +/** + * Utility class to access Car Restriction Manager. + * + * <p>This class must be a singleton because only one listener can be registered with {@link + * CarUxRestrictionsManager} at a time, as documented in {@link + * CarUxRestrictionsManager#registerListener}. + */ +// TODO(b/187312393): Rename to CarUxRestrictionsManager +public class CarUxRestrictionsUtil { + private static final String TAG = "CarUxRestrictionsUtil"; + + @NonNull private CarUxRestrictions mCarUxRestrictions = getDefaultRestrictions(); + + private final Set<OnUxRestrictionsChangedListener> mObservers = + Collections.newSetFromMap(new WeakHashMap<>()); + + private static CarUxRestrictionsUtil sInstance = null; + + private CarUxRestrictionsUtil(Context context) { + CarUxRestrictionsManager.OnUxRestrictionsChangedListener listener = + (carUxRestrictions) -> { + if (carUxRestrictions == null) { + mCarUxRestrictions = getDefaultRestrictions(); + } else { + mCarUxRestrictions = carUxRestrictions; + } + + ThreadUtils.runOnMain( + () -> { + for (OnUxRestrictionsChangedListener observer : mObservers) { + observer.onRestrictionsChanged(mCarUxRestrictions); + } + }); + }; + + try { + Car carApi = Car.createCar(context.getApplicationContext()); + + try { + CarUxRestrictionsManager carUxRestrictionsManager = + (CarUxRestrictionsManager) carApi.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE); + carUxRestrictionsManager.registerListener(listener); + listener.onUxRestrictionsChanged(carUxRestrictionsManager.getCurrentCarUxRestrictions()); + } catch (NullPointerException e) { + Log.e(TAG, "Car not connected", e); + // mCarUxRestrictions will be the default + } + } catch (SecurityException e) { + Log.w(TAG, "Unable to connect to car service, assuming unrestricted", e); + listener.onUxRestrictionsChanged( + new CarUxRestrictions.Builder(false, CarUxRestrictions.UX_RESTRICTIONS_BASELINE, 0) + .build()); + } + } + + @NonNull + private static CarUxRestrictions getDefaultRestrictions() { + return new CarUxRestrictions.Builder( + true, CarUxRestrictions.UX_RESTRICTIONS_FULLY_RESTRICTED, 0) + .build(); + } + + /** Listener interface used to update clients on UxRestrictions changes */ + public interface OnUxRestrictionsChangedListener { + /** Called when CarUxRestrictions changes */ + void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions); + } + + /** Returns the singleton sInstance of this class */ + @NonNull + public static CarUxRestrictionsUtil getInstance(Context context) { + if (sInstance == null) { + sInstance = new CarUxRestrictionsUtil(context); + } + + return sInstance; + } + + /** + * Registers a listener on this class for updates to CarUxRestrictions. Multiple listeners may be + * registered. Note that this class will only hold a weak reference to the listener, you must + * maintain a strong reference to it elsewhere. + */ + public void register(OnUxRestrictionsChangedListener listener) { + + ThreadUtils.runOnMain( + () -> { + mObservers.add(listener); + listener.onRestrictionsChanged(mCarUxRestrictions); + }); + } + + /** Unregisters a registered listener */ + public void unregister(OnUxRestrictionsChangedListener listener) { + ThreadUtils.runOnMain(() -> mObservers.remove(listener)); + } + + @NonNull + public CarUxRestrictions getCurrentRestrictions() { + return mCarUxRestrictions; + } + + /** + * Returns whether any of the given flags are blocked by the specified restrictions. If null is + * given, the method returns true for safety. + */ + public static boolean isRestricted( + @CarUxRestrictionsInfo int restrictionFlags, @Nullable CarUxRestrictions uxr) { + return (uxr == null) || ((uxr.getActiveRestrictions() & restrictionFlags) != 0); + } + + /** Sets car UX restrictions. Only used for testing. */ + @VisibleForTesting + public void setUxRestrictions(CarUxRestrictions carUxRestrictions) { + mCarUxRestrictions = carUxRestrictions; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ClusterIconContentProvider.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ClusterIconContentProvider.kt new file mode 100644 index 0000000..8f757a1 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ClusterIconContentProvider.kt @@ -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.templates.host.internal + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.UriMatcher +import android.database.Cursor +import android.database.MatrixCursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.core.content.contentValuesOf +import androidx.core.graphics.scale +import androidx.core.net.toUri +import com.android.car.libraries.apphost.logging.L +import com.android.car.libraries.apphost.logging.LogTags +import com.android.car.libraries.apphost.logging.TelemetryEvent.UiAction.HOST_FAILURE_CLUSTER_ICON +import com.android.car.libraries.templates.host.internal.ClusterIconContentProvider.Companion.addToCache +import com.android.car.libraries.templates.host.internal.ClusterIconContentProvider.Companion.queryIconData +import com.google.common.cache.Cache +import com.google.common.cache.CacheBuilder +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import com.android.car.libraries.templates.host.R + +/** + * A [ContentProvider] for providing navigation state icons to the Cluster. + * + * It uses an in-memory cache of [Bitmap]s that can be retrieved with a [Uri]. Use [addToCache] to + * add a bitmap to the cache. Use [queryIconData] to check for the existence of a bitmap in cache + * (note that this will refresh the validity of the entry). + */ +class ClusterIconContentProvider : ContentProvider() { + private val scope = CoroutineScope(Dispatchers.IO) + private lateinit var iconProviderDelegate: IconProviderDelegate + + override fun onCreate(): Boolean { + val context = checkNotNull(context) + + val timeoutMillis = + context.resources.getInteger(R.integer.cluster_icon_cache_duration_millis).toLong() + + val authority = authority(context) + iconProviderDelegate = IconProviderDelegate(authority, timeoutMillis, scope) + + return true + } + + override fun shutdown() { + scope.cancel("ContentProvider shutting down") + iconProviderDelegate.shutdown() + super.shutdown() + } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + return iconProviderDelegate.openFile(uri) + } + + /** + * Get the uri and aspect ratio of an icon if it exists in cache. Prefer to use the convenience + * method [queryIconData], instead of calling this directly. + * + * @param selection the iconId to look up + * @return a [Cursor] with or 1 row if a match was found, or 0 rows otherwise + */ + override fun query( + uri: Uri, + projection: Array<String>?, + selection: String?, + selectionArgs: Array<String>?, + sortOrder: String? + ): Cursor { + val cursor = + MatrixCursor( + arrayOf(QUERY_RESULT_CONTENT_URI, QUERY_RESULT_ASPECT_RATIO), + /* initialCapacity */ 1 + ) + iconProviderDelegate.query(selection)?.let { (contentUri, aspectRatio) -> + cursor.addRow(arrayOf(contentUri, aspectRatio)) + } + + return cursor + } + + /** + * Converts the provided [ByteArray] to a Bitmap and caches it, returning the URI path for this + * icon. There are no stability guarantees for the keys / expected values, so prefer to use the + * convenience method [addToCache], instead of calling this directly. + */ + override fun insert(uri: Uri, values: ContentValues?): Uri? { + if (values == null) return null + val iconId = values.getAsString(INSERT_PARAM_ICON_ID) ?: return null + val bytes = values.getAsByteArray(INSERT_PARAM_BITMAP_BYTES) ?: return null + return iconProviderDelegate.cacheIcon(bytes, iconId) + } + + override fun getType(uri: Uri): String? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array<String>? + ): Int = 0 + + companion object { + + private const val INSERT_PARAM_ICON_ID = "iconId" + private const val INSERT_PARAM_BITMAP_BYTES = "data" + + private const val QUERY_RESULT_CONTENT_URI = "contentUri" + private const val QUERY_RESULT_ASPECT_RATIO = "aspectRatio" + + /** + * @return a uri and aspect ratio for the icon if it already exists in cache. [null] otherwise + */ + fun queryIconData(iconId: String, context: Context): Pair<String, Double>? { + context.contentResolver.query(contentUri(context), null, iconId, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val contentUriIndex = cursor.getColumnIndex(QUERY_RESULT_CONTENT_URI) + val aspectRatioIndex = cursor.getColumnIndex(QUERY_RESULT_ASPECT_RATIO) + if (contentUriIndex >= 0 && aspectRatioIndex >= 0) { + val contentUri = cursor.getString(contentUriIndex) + val aspectRatio = cursor.getDouble(aspectRatioIndex) + return contentUri to aspectRatio + } else { + L.w(LogTags.CLUSTER) { + "Icon for id $iconId exists, but failed to extract URI/aspectRatio" + } + } + } + } + return null + } + + /** Saves the bitmap to an in-memory cache, and returns a Uri that can be used to access it. */ + fun addToCache(iconId: String, bitmapBytes: ByteArray, context: Context): Uri? { + return context.contentResolver.insert( + contentUri(context), + contentValuesOf(INSERT_PARAM_ICON_ID to iconId, INSERT_PARAM_BITMAP_BYTES to bitmapBytes) + ) + } + + private fun contentUri(context: Context) = "content://${authority(context)}".toUri() + + /** Returns the provider's authority, as defined in the Manifest. */ + private fun authority(context: Context) = "${context.packageName}.ClusterIconContentProvider" + } +} + +/** + * This class extracts most of the logic out of [ClusterIconContentProvider] so it can be more + * easily tested. + */ +class IconProviderDelegate( + private val authority: String, + cacheTimeoutMillis: Long, + private val coroutineScope: CoroutineScope +) { + private var cache: Cache<String, Bitmap> = + CacheBuilder.newBuilder().expireAfterAccess(cacheTimeoutMillis, TimeUnit.MILLISECONDS).build() + + private val uriMatcher = + UriMatcher(UriMatcher.NO_MATCH).apply { addURI(authority, "img/*", URI_IMAGE_CODE) } + + fun cacheIcon(bytes: ByteArray, iconId: String): Uri { + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + val key = keyForIconId(iconId) + + cache.put(key, bitmap) + + return uriForKey(key) + } + + /** @return a [Pair] with ContentUri and AspectRatio if a match was found, [null] otherwise */ + fun query(iconId: String?): Pair<Uri, Double>? { + if (iconId == null) return null + val key = keyForIconId(iconId) + val bitmap = cache.getIfPresent(key) ?: return null + + val contentUri = uriForKey(key) + val aspectRatio = bitmap.width.toDouble() / bitmap.height.toDouble() + return contentUri to aspectRatio + } + + /** Returns a [ParcelFileDescriptor] that will be written to asynchronously */ + fun openFile(uri: Uri): ParcelFileDescriptor { + return when (uriMatcher.match(uri)) { + URI_IMAGE_CODE -> { + val key = + requireNotNull(uri.lastPathSegment) { + "Cluster icon requested but no key provided. URI=$uri" + } + + val bitmap = + cache.getIfPresent(key) + ?: run { + LogUtil.log(HOST_FAILURE_CLUSTER_ICON) + throw IllegalStateException("Requested cluster icon that's not in cache. (key=$key)") + } + val width = uri.getQueryParameter("w")?.toIntOrNull() ?: bitmap.width + val height = uri.getQueryParameter("h")?.toIntOrNull() ?: bitmap.height + // TODO(b/197754774): Cache scaled bitmaps + val scaledBitmap = + if (width != bitmap.width || height != bitmap.height) { + bitmap.scale(width, height) + } else { + bitmap + } + + // Use a pipe to avoid eagerly saving bitmaps to disk (or at all) + val (readPipe, writePipe) = ParcelFileDescriptor.createReliablePipe() + + // asynchronously write bitmap to output stream + writeToPipeAsync(writePipe, scaledBitmap) + + // Give the readPipe for cluster to consume the bitmap + readPipe + } + else -> + throw IllegalArgumentException("Requested a path that doesn't correspond to an icon: $uri") + } + } + + private fun writeToPipeAsync(writePipe: ParcelFileDescriptor, bitmap: Bitmap) = + coroutineScope.launch { + runCatching { + L.d(LogTags.CLUSTER) { "Writing bitmap to pipe" } + ParcelFileDescriptor.AutoCloseOutputStream(writePipe).use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, /* quality= */ 100, outputStream) + } + } + .onFailure { + L.e(LogTags.CLUSTER, it) { "IOException writing cluster icon to pipe" } + writePipe.closeWithError("IOException writing to pipe") + } + } + + fun shutdown() { + cache.invalidateAll() + } + + private fun keyForIconId(iconId: String) = "cluster_icon_$iconId" + + private fun uriForKey(key: String) = "content://${authority}/img/$key".toUri() + + companion object { + private const val URI_IMAGE_CODE = 1 + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ColorContrastCheckStateImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ColorContrastCheckStateImpl.kt new file mode 100644 index 0000000..21e694e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ColorContrastCheckStateImpl.kt @@ -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.templates.host.internal + +import com.android.car.libraries.apphost.common.ColorContrastCheckState + +/** Manages the state of color contrast checks in template apps. */ +class ColorContrastCheckStateImpl : ColorContrastCheckState { + private var checkPassed = true + override fun setCheckPassed(passed: Boolean) { + checkPassed = passed + } + + override fun getCheckPassed(): Boolean { + return checkPassed + } + + override fun checksContrast(): Boolean { + return true + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CommonUtils.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CommonUtils.kt new file mode 100644 index 0000000..21b74cc --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/CommonUtils.kt @@ -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.templates.host.internal + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Build + +/** Holds static util methods for common usage in the host. */ +object CommonUtils { + /** + * Key for the extra that we insert into an Intent to mark it as coming from a notification + * action. + */ + const val EXTRA_NOTIFICATION_INTENT = "CAR_APP_NOTIFICATION_INTENT" + + /** Checks whether the templates host is currently running on an emulator. */ + private fun isConnectedToEmulator(): Boolean { + return Build.PRODUCT.contains("gcar") || + Build.FINGERPRINT.contains("unknown") || + Build.FINGERPRINT.contains("emu") || + Build.DEVICE.contains("generic") || + Build.DEVICE.contains("emu") + } + + /** Checks whether the templates host has debug mode enabled */ + fun isDebugEnabled(context: Context): Boolean { + return isConnectedToEmulator() || + (0 != context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ConstraintsProviderImpl.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ConstraintsProviderImpl.java new file mode 100644 index 0000000..d081426 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ConstraintsProviderImpl.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal; + +import android.car.drivingstate.CarUxRestrictions; +import android.content.Context; +import android.content.res.TypedArray; +import androidx.annotation.NonNull; +import androidx.annotation.StyleableRes; +import androidx.car.app.constraints.ConstraintManager; +import com.android.car.libraries.apphost.common.EventManager; +import com.android.car.libraries.apphost.distraction.constraints.ConstraintsProvider; +import com.android.car.libraries.templates.host.di.UxreConfig; +import com.android.car.libraries.templates.host.R; + +/** Provides different limit values for the car app. */ +public final class ConstraintsProviderImpl implements ConstraintsProvider { + private final Context mContext; + private final EventManager mEventManager; + private final UxreConfig mUxreConfig; + private final int mListMaxLength; + private final int mGridMaxLength; + + private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener = + new UxRestrictionChangedListener(); + + private CarUxRestrictions mCurrentRestrictions; + + @SuppressWarnings({"ResourceType"}) + public ConstraintsProviderImpl( + Context context, EventManager eventManager, UxreConfig uxreConfig) { + mContext = context; + mEventManager = eventManager; + mUxreConfig = uxreConfig; + + CarUxRestrictionsUtil carUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(mContext); + carUxRestrictionsUtil.register(mListener); + mCurrentRestrictions = carUxRestrictionsUtil.getCurrentRestrictions(); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateListMaxLength, R.attr.templateGridMaxLength, + }; + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mListMaxLength = ta.getInt(0, 6); + mGridMaxLength = ta.getInt(1, 6); + ta.recycle(); + } + + @Override + public int getContentLimit(int contentType) { + + switch (contentType) { + case ConstraintManager.CONTENT_LIMIT_TYPE_PLACE_LIST: + case ConstraintManager.CONTENT_LIMIT_TYPE_LIST: + // TODO(b/186693941): For Android T and above, introduce an accurate UXRE API + // representing single-page limit so that we don't have to rely on the + // the getMaxCumulativeContentItems() API. + return mUxreConfig.getListMaxLength(mListMaxLength); + case ConstraintManager.CONTENT_LIMIT_TYPE_GRID: + // TODO(b/186693941): For Android T and above, introduce an accurate UXRE API + // representing single-page limit so that we don't have to rely on the + // the getMaxCumulativeContentItems() API. + return mUxreConfig.getGridMaxLength(mGridMaxLength); + case ConstraintManager.CONTENT_LIMIT_TYPE_PANE: + return mUxreConfig.getPaneMaxLength( + mContext.getResources().getInteger(R.integer.pane_max_length)); + case ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST: + return mUxreConfig.getRouteListMaxLength( + mContext.getResources().getInteger(R.integer.route_list_max_length)); + default: + throw new IllegalArgumentException("Unknown content type: " + contentType); + } + } + + @Override + public int getTemplateStackMaxSize() { + return mUxreConfig.getTemplateStackMaxSize( + mContext.getResources().getInteger(R.integer.template_stack_max_size)); + } + + @Override + public boolean isKeyboardRestricted() { + return CarUxRestrictionsUtil.isRestricted( + CarUxRestrictions.UX_RESTRICTIONS_NO_KEYBOARD, mCurrentRestrictions); + } + + @Override + public boolean isConfigRestricted() { + return CarUxRestrictionsUtil.isRestricted( + CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP, mCurrentRestrictions); + } + + @Override + public boolean isFilteringRestricted() { + return CarUxRestrictionsUtil.isRestricted( + CarUxRestrictions.UX_RESTRICTIONS_NO_FILTERING, mCurrentRestrictions); + } + + @Override + public int getStringCharacterLimit() { + return mCurrentRestrictions.getMaxRestrictedStringLength(); + } + + void update(CarUxRestrictions restrictions) { + mCurrentRestrictions = restrictions; + mEventManager.dispatchEvent(EventManager.EventType.CONSTRAINTS); + } + + private class UxRestrictionChangedListener + implements CarUxRestrictionsUtil.OnUxRestrictionsChangedListener { + + @Override + public void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions) { + update(carUxRestrictions); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/DebugOverlayHandlerImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/DebugOverlayHandlerImpl.kt new file mode 100644 index 0000000..39018d6 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/DebugOverlayHandlerImpl.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import androidx.car.app.model.TemplateWrapper +import com.android.car.libraries.apphost.common.DebugOverlayHandler +import java.util.LinkedHashMap + +/** The handler for the template-specific debug overlay. */ +class DebugOverlayHandlerImpl(private var isDebugOverlayActive: Boolean) : DebugOverlayHandler { + /** Using a linked hashmap here to keep track of debug entries' orders */ + private val debugTextMap: HashMap<String, String> = LinkedHashMap() + + private val builder = StringBuilder() + + private var observer: DebugOverlayHandler.Observer? = null + + override fun setActive(active: Boolean) { + isDebugOverlayActive = active + observer?.entriesUpdated() + } + + override fun isActive(): Boolean { + return isDebugOverlayActive + } + + override fun clearAllEntries() { + debugTextMap.clear() + } + + override fun removeDebugOverlayEntry(debugKey: String) { + debugTextMap.remove(debugKey) + } + + override fun updateDebugOverlayEntry(debugKey: String, debugOverlayText: String) { + debugTextMap[debugKey] = debugOverlayText + } + + override fun getDebugOverlayText(): CharSequence { + builder.setLength(0) + var needsNewLineBefore = false + for (key in debugTextMap.keys) { + if (needsNewLineBefore) { + builder.append("\n") + } + builder.append(key).append(": ").append(debugTextMap[key]) + needsNewLineBefore = true + } + return builder.toString() + } + + override fun setObserver(observer: DebugOverlayHandler.Observer?) { + this.observer = observer + observer?.entriesUpdated() + } + + override fun resetTemplateDebugOverlay(templateWrapper: TemplateWrapper) { + clearAllEntries() + updateDebugOverlayEntry( + /* debugKey= */ "Step", + Integer.toString(templateWrapper.currentTaskStep) + ) + updateDebugOverlayEntry( + /* debugKey= */ "Template", + templateWrapper.template.javaClass.simpleName + ) + observer?.entriesUpdated() + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ErrorHandlerImpl.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ErrorHandlerImpl.java new file mode 100644 index 0000000..40e8681 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ErrorHandlerImpl.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal; + +import android.content.ComponentName; +import android.content.Context; +import androidx.car.app.CarContext; +import androidx.car.app.model.MessageTemplate; +import androidx.car.app.model.TemplateWrapper; +import com.android.car.libraries.apphost.CarHost; +import com.android.car.libraries.apphost.common.CarAppError; +import com.android.car.libraries.apphost.common.CarAppManager; +import com.android.car.libraries.apphost.common.ErrorHandler; +import com.android.car.libraries.apphost.common.ErrorMessageTemplateBuilder; +import com.android.car.libraries.apphost.common.HostResourceIds; +import com.android.car.libraries.apphost.logging.L; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.libraries.apphost.template.AppHost; + +/** + * Handles error cases, allowing classes that do not handle ui to be able to display an error screen + * to the user. + */ +public class ErrorHandlerImpl implements ErrorHandler { + private final Context mContext; + private final ComponentName mAppName; + private final CarAppManager mCarAppManager; + private final HostResourceIds mHostResourceIdsImpl; + + /** Returns a {@link ErrorHandlerImpl} to show an error screen */ + public static ErrorHandlerImpl create( + Context context, + ComponentName appName, + CarAppManager carAppManager, + HostResourceIds hostResourceIds) { + return new ErrorHandlerImpl(context, appName, carAppManager, hostResourceIds); + } + + private ErrorHandlerImpl( + Context context, + ComponentName appName, + CarAppManager carAppManager, + HostResourceIds hostResourceIds) { + mContext = context; + mAppName = appName; + mCarAppManager = carAppManager; + mHostResourceIdsImpl = hostResourceIds; + } + + @Override + public void showError(CarAppError error) { + Throwable cause = error.getCause(); + if (cause != null) { + if (error.logVerbose()) { + L.v(LogTags.TEMPLATE, cause, "Error: %s", error); + } else { + L.e(LogTags.TEMPLATE, cause, "Error: %s", error); + } + } else { + if (error.logVerbose()) { + L.v(LogTags.TEMPLATE, "Error: %s", error); + } else { + L.e(LogTags.TEMPLATE, "Error: %s", error); + } + } + + MessageTemplate errorMessageTemplate = + new ErrorMessageTemplateBuilder( + mContext, + error, + mHostResourceIdsImpl, + // TODO(b/183145188): finish car app should not kill the host, just + // the activity + mCarAppManager::finishCarApp) + .build(); + + CarHost carHost = CarHostRepository.INSTANCE.get(mAppName); + AppHost apphost = (AppHost) carHost.getHostOrThrow(CarContext.APP_SERVICE); + apphost.getUIController().setTemplate(mAppName, TemplateWrapper.wrap(errorMessageTemplate)); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InputConfigImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InputConfigImpl.kt new file mode 100644 index 0000000..f49181f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InputConfigImpl.kt @@ -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.templates.host.internal + +import com.android.car.libraries.apphost.input.InputConfig + +/** Manages the state of routing information in template apps. */ +class InputConfigImpl : InputConfig { + override fun hasTouchpadForUiNavigation(): Boolean { + // TODO(b/188454942): Retrieve the input configuration from AAOS system + return false + } + + override fun hasTouch(): Boolean { + // TODO(b/188454942): Retrieve the input configuration from AAOS system + return true + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InputManagerImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InputManagerImpl.kt new file mode 100644 index 0000000..922cbc7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InputManagerImpl.kt @@ -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.templates.host.internal + +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.inputmethod.EditorInfo +import androidx.car.app.activity.renderer.IProxyInputConnection +import com.android.car.libraries.apphost.input.CarEditable +import com.android.car.libraries.apphost.input.CarEditableListener +import com.android.car.libraries.apphost.input.InputManager +import com.android.car.libraries.apphost.logging.LogTags + +/** The app specific implementation of [InputManager]. */ +class InputManagerImpl(private val listener: InputManagerListener) : InputManager { + + /** A listener to be notified for input related events. */ + interface InputManagerListener { + /* Should start the input, i.e. show soft keyboard */ + fun onStartInput() + + /* Should stop the input, i.e. hide soft keyboard */ + fun onStopInput() + + /* + * Update the text selection. Gets called whenever text selection changes on the + * [currentEditable]. + */ + fun onUpdateSelection(oldSelStart: Int, oldSelEnd: Int, newSelStart: Int, newSelEnd: Int) + } + + private var currentEditable: CarEditable? = null + private val handler = Handler(Looper.getMainLooper()) + + private var stopInputRunnable = Runnable { + if (isInputActive) { + currentEditable = null + listener.onStopInput() + } + } + + private val carEditableListener = + CarEditableListener { oldSelStart, oldSelEnd, newSelStart, newSelEnd -> + listener.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd) + } + + override fun startInput(view: CarEditable) { + currentEditable?.setCarEditableListener(null) + currentEditable = view + currentEditable?.setCarEditableListener(carEditableListener) + + // Cancel any ongoing stop input to avoid jarring keyboard animations. + handler.removeCallbacks(stopInputRunnable) + + listener.onStartInput() + } + + override fun stopInput() { + currentEditable?.setCarEditableListener(null) + + // Perform stop input with a delay to avoid jarring keyboard disappear+reappear animation + // when switching form one focusable to another. + handler.removeCallbacks(stopInputRunnable) + handler.postDelayed(stopInputRunnable, STOP_INPUT_DELAY_MILLIS) + } + + override fun isValid() = true + override fun isInputActive() = currentEditable != null + + fun onCreateInputConnection(editorInfo: EditorInfo): IProxyInputConnection? { + val currentEditable = + currentEditable + ?: run { + Log.d(LogTags.APP_HOST, "There is no focusable target selected.") + return null + } + val inputConnection = + currentEditable.onCreateInputConnection(editorInfo) + ?: run { + Log.d(LogTags.APP_HOST, "Failed to create input connection for editorInfo $editorInfo") + return null + } + return ProxyInputConnection(inputConnection, editorInfo) + } + + companion object { + const val STOP_INPUT_DELAY_MILLIS = 100L + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InsetsListener.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InsetsListener.kt new file mode 100644 index 0000000..edd4d00 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/InsetsListener.kt @@ -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.templates.host.internal + +import android.graphics.Insets +import android.os.Build +import android.view.WindowInsets +import androidx.car.app.activity.renderer.IInsetsListener +import androidx.car.app.utils.ThreadUtils +import com.android.car.libraries.apphost.logging.L +import com.android.car.libraries.apphost.logging.LogTags +import com.android.car.libraries.apphost.view.AbstractTemplateView + +/** Handles window insets from the car app. */ +class InsetsListener(private val templateView: AbstractTemplateView) : IInsetsListener.Stub() { + override fun onInsetsChanged(insets: Insets) { + ThreadUtils.runOnMain { + L.i(LogTags.APP_HOST) { "Received insets: $insets" } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + templateView.windowInsets = + WindowInsets.Builder() + .setInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime(), insets) + .build() + } else { + templateView.windowInsets = WindowInsets.Builder().setSystemWindowInsets(insets).build() + } + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/LogUtil.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/LogUtil.kt new file mode 100644 index 0000000..c6b2272 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/LogUtil.kt @@ -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.templates.host.internal + +import android.content.ComponentName +import android.content.Context +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.TelemetryHandler +import com.android.car.libraries.templates.host.di.TelemetryHandlerFactory + +/** + * Holds static telemetry logging methods for common usage in the host. These methods should only be + * used by service level component, e.g. [ClusterIconContentProvider]. Car app level logging should + * use [TelemetryHandler] from TemplateContext + */ +class LogUtil(private val telemetryHandler: TelemetryHandler) { + + companion object { + private lateinit var instance: LogUtil + + fun init(telemetryHandlerFactory: TelemetryHandlerFactory, applicationContext: Context?) { + checkNotNull(applicationContext) + instance = + LogUtil( + telemetryHandlerFactory.create( + applicationContext, + ComponentName(applicationContext, LogUtil::class.java) + ) + ) + } + + fun log(uiAction: TelemetryEvent.UiAction) { + log(TelemetryEvent.newBuilder(uiAction)) + } + + private fun log(builder: TelemetryEvent.Builder) { + if (!this::instance.isInitialized) { + L.d(LogTags.APP_HOST) { "CommonLogger is not initialized" } + return + } + instance.telemetryHandler.logCarAppTelemetry(builder) + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationCoordinator.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationCoordinator.kt new file mode 100644 index 0000000..443559b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationCoordinator.kt @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.car.Car +import android.car.CarAppFocusManager +import android.car.CarAppFocusManager.APP_FOCUS_TYPE_NAVIGATION as APP_TYPE_NAVIGATION +import android.car.CarAppFocusManager.OnAppFocusOwnershipCallback as FocusCallback +import android.car.cluster.navigation.NavigationState +import android.car.navigation.CarNavigationStatusManager +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.car.app.navigation.model.Trip +import androidx.core.os.bundleOf +import com.android.car.libraries.apphost.common.CarAppPackageInfo +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.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import com.android.car.libraries.templates.host.R + + +/** + * Coordinate navigation and focus control between all Template Apps. Apps can start sending + * navigation events after requesting focus, but it's possible some events will be dropped while + * focus is being obtained. + */ +class NavigationCoordinator +private constructor( + carAppFocusManagerProvider: () -> CarAppFocusManager?, + carNavStatusManagerProvider: () -> CarNavigationStatusManager?, + private val shouldShareNavState: Boolean +) : FocusCallback { + /** + * Instances of this interface will be compared against each other for equality. Either make sure + * you are sending the same instance per app, or implement [equals] to account for this. + */ + interface NavAppFocusOwner { + val packageInfo: CarAppPackageInfo + fun onFocusLost() + } + + private val focusManager: CarAppFocusManager? by lazy(carAppFocusManagerProvider) + private val navigationManager: CarNavigationStatusManager? by lazy(carNavStatusManagerProvider) + + private val _navigationState = MutableStateFlow<HostNavState>(HostNavState.NotNavigating) + val navigationState = _navigationState.asStateFlow() + + /** Whether or not the Host has navigation focus currently */ + private val isOwningFocus = AtomicBoolean(false) + private var currentNavApp: NavAppFocusOwner? = null + + /** + * Apps must request Focus (see [requestAppFocus]) before sending navigation events. Also, there's + * a chance that some events will be dropped on the floor while focus is being obtained. + */ + fun sendNavigationStateChange( + navApp: NavAppFocusOwner, + // TODO(b/206694446): Only accept Trip and do the Proto conversion here. + navigationState: NavigationState.NavigationStateProto, + templateContext: TemplateContext, + trip: Trip? = null + ) = + synchronized(this) { + if (isFocused(navApp)) { + _navigationState.value = + if (trip != null) HostNavState.Navigating(trip, templateContext, navApp.packageInfo) + else HostNavState.NotNavigating + if (shouldShareNavState) { + navigationManager?.sendNavigationStateChange(navigationState.asBundle()) + } + } else { + L.w(LogTags.NAVIGATION) { + val packageName = navApp.packageInfo.componentName.packageName + "Package $packageName is trying to send NavigationState updates without owning focus" + } + } + } + + /** + * Note that a result of [CarAppFocusManager.APP_FOCUS_REQUEST_SUCCEEDED] does not mean you have + * focus yet. This call is asynchronous and + * [CarAppFocusManager.OnAppFocusOwnershipCallback.onAppFocusOwnershipGranted] will be called when + * focus is granted for the app. + */ + fun requestAppFocus(navApp: NavAppFocusOwner) = + synchronized(this) { + val focusManager = + focusManager + ?: run { + L.w(LogTags.NAVIGATION) { + "Couldn't obtain focusManager. Are you missing a permission?" + } + navApp.onFocusLost() + return + } + + if (navApp != currentNavApp) { + currentNavApp?.onFocusLost() + currentNavApp = navApp + } + + if (!isOwningFocus.get()) { + // request focus from system + val result = focusManager.requestAppFocus(APP_TYPE_NAVIGATION, this) + if (result == CarAppFocusManager.APP_FOCUS_REQUEST_FAILED) { + onAppFocusOwnershipLost(APP_TYPE_NAVIGATION) + } + } else { + clearNavState() + } + } + + /** Notify that [navApp] is done navigation and no longer requires focus. */ + fun abandonAppFocus(navApp: NavAppFocusOwner) = + synchronized(this) { + if (isFocused(navApp)) { + onAppFocusOwnershipLost(APP_TYPE_NAVIGATION) + focusManager?.abandonAppFocus(this, APP_TYPE_NAVIGATION) + _navigationState.value = HostNavState.NotNavigating + } + } + + override fun onAppFocusOwnershipLost(appType: Int) = + synchronized(this) { + L.d(LogTags.NAVIGATION) { "Host focus Lost" } + isOwningFocus.set(false) + currentNavApp?.onFocusLost() + currentNavApp = null + } + + override fun onAppFocusOwnershipGranted(appType: Int) = + synchronized(this) { + L.d(LogTags.NAVIGATION) { "Host focus granted" } + isOwningFocus.set(true) + if (!shouldShareNavState) { + L.d(LogTags.NAVIGATION, "NavState data will not sent to system.") + clearNavState() + } + } + + private fun clearNavState() { + val emptyNavState = NavigationState.NavigationStateProto.getDefaultInstance() + navigationManager?.sendNavigationStateChange(emptyNavState.asBundle()) + } + + /** returns whether or not [navApp] has focus currently */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun isFocused(navApp: NavAppFocusOwner): Boolean = + synchronized(this) { isOwningFocus.get() && this.currentNavApp == navApp } + + companion object { + private lateinit var instance: NavigationCoordinator + + fun getInstance(context: Context): NavigationCoordinator { + if (!this::instance.isInitialized) { + val themeAttrs = intArrayOf(R.attr.templateSendNavStateToSystem) + val ta = context.obtainStyledAttributes(themeAttrs) + val shouldShareNavState = + ta.getBoolean(0, context.resources.getBoolean(R.bool.send_navstates_to_system)) + ta.recycle() + instance = + NavigationCoordinator( + carAppFocusManagerProvider = { context.getCarService(Car.APP_FOCUS_SERVICE) }, + carNavStatusManagerProvider = { context.getCarService(Car.CAR_NAVIGATION_SERVICE) }, + shouldShareNavState + ) + } + return instance + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun testInstance( + carAppFocusManager: CarAppFocusManager, + carNavStatusManager: CarNavigationStatusManager + ) = NavigationCoordinator({ carAppFocusManager }, { carNavStatusManager }, true) + + private inline fun <reified T> Context.getCarService(serviceName: String): T? { + val car: Car? = Car.createCar(this) + if (car == null) { + L.e(LogTags.NAVIGATION) { "Nav state disabled: Unable to connect to CarService" } + return null + } + return runCatching { car.getCarManager(serviceName) as T? } + .onSuccess { L.d(LogTags.NAVIGATION) { "Obtained service: $serviceName" } } + .onFailure { + L.e(LogTags.NAVIGATION, it) { + "Nav state disabled: Unable to obtain access to $serviceName." + } + } + .getOrNull() + } + } +} + +private const val NAVIGATION_STATE_PROTO_BUNDLE_KEY = "navstate2" + +private fun NavigationState.NavigationStateProto.asBundle() = + bundleOf(NAVIGATION_STATE_PROTO_BUNDLE_KEY to this.toByteArray()) + +sealed class HostNavState { + object NotNavigating : HostNavState() + class Navigating( + val trip: Trip, + val templateContext: TemplateContext, + val packageInfo: CarAppPackageInfo + ) : HostNavState() +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateCallbackImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateCallbackImpl.kt new file mode 100644 index 0000000..80d68fc --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateCallbackImpl.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.car.cluster.navigation.NavigationState +import androidx.car.app.navigation.model.Trip +import com.android.car.libraries.apphost.common.CarAppPackageInfo +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.nav.NavigationHost +import com.android.car.libraries.apphost.nav.NavigationStateCallback +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import com.android.car.libraries.templates.host.R + +/** Handles navigation state change events from [NavigationHost]. */ +class NavigationStateCallbackImpl +private constructor( + private val templateContext: TemplateContext, + private val navigationStateConverter: NavigationStateConverter +) : NavigationStateCallback { + private var onNavigationStopRunnable: Runnable? = null + + private val packageInfo = templateContext.carAppPackageInfo + private val navigationCoordinator by lazy { NavigationCoordinator.getInstance(templateContext) } + + private val navApp = + object : NavigationCoordinator.NavAppFocusOwner { + override val packageInfo: CarAppPackageInfo + get() = templateContext.carAppPackageInfo + + override fun onFocusLost() { + onNavigationStopRunnable?.run() + } + } + + override fun onUpdateTrip(trip: Trip): Boolean { + L.v(LogTags.NAVIGATION) { "onUpdateTrip $packageInfo" } + CoroutineScope(Dispatchers.Default).launch { + // Conversion shouldn't take a long time. If it hangs for too long, just kill it. + val timeMillis = + templateContext + .resources + .getInteger(R.integer.cluster_trip_to_navstate_conversion_timeout_millis) + .toLong() + val navigationState = + withTimeout(timeMillis) { navigationStateConverter.tripToNavigationState(trip) } + navigationCoordinator.sendNavigationStateChange( + navApp, + navigationState, + templateContext, + trip + ) + } + return true + } + + override fun onNavigationStarted(onNavigationStopRunnable: Runnable) { + L.v(LogTags.NAVIGATION) { "onNavigationStarted ${templateContext.carAppPackageInfo}" } + + this.onNavigationStopRunnable = onNavigationStopRunnable + if (templateContext.carHostConfig.isClusterEnabled) { + navigationCoordinator.requestAppFocus(navApp) + } + } + + override fun onNavigationEnded() { + L.v(LogTags.NAVIGATION) { "onNavigationEnded ${templateContext.carAppPackageInfo}" } + + // Remove directions from cluster + navigationCoordinator.sendNavigationStateChange( + navApp, + NavigationState.NavigationStateProto.getDefaultInstance(), + templateContext + ) + + navigationCoordinator.abandonAppFocus(navApp) + + onNavigationStopRunnable = null + } + + companion object { + fun create(templateContext: TemplateContext): NavigationStateCallback { + return NavigationStateCallbackImpl( + templateContext, + NavigationStateConverterImpl(templateContext) + ) + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateConverter.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateConverter.kt new file mode 100644 index 0000000..2510f4d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateConverter.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.car.cluster.navigation.NavigationState +import android.car.navigation.CarNavigationStatusManager +import androidx.car.app.navigation.NavigationManager +import androidx.car.app.navigation.model.Trip + +/** + * Convert a [Trip] (from [NavigationManager]) to a [NavigationState.NavigationStateProto] that the + * Cluster can parse and display (via [CarNavigationStatusManager.sendNavigationStateChange]). + */ +interface NavigationStateConverter { + suspend fun tripToNavigationState(trip: Trip): NavigationState.NavigationStateProto +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateConverterImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateConverterImpl.kt new file mode 100644 index 0000000..2ff9d6a --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/NavigationStateConverterImpl.kt @@ -0,0 +1,322 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.car.cluster.navigation.CueKt.cueElement +import android.car.cluster.navigation.LaneKt.laneDirection +import android.car.cluster.navigation.NavigationState +import android.car.cluster.navigation.NavigationState.Lane.LaneDirection.Shape +import android.car.cluster.navigation.NavigationState.Maneuver.Type as ManeuverType +import android.car.cluster.navigation.NavigationState.NavigationStateProto.ServiceStatus.NORMAL +import android.car.cluster.navigation.NavigationState.NavigationStateProto.ServiceStatus.REROUTING +import android.car.cluster.navigation.cue +import android.car.cluster.navigation.destination +import android.car.cluster.navigation.distance +import android.car.cluster.navigation.lane +import android.car.cluster.navigation.maneuver +import android.car.cluster.navigation.navigationStateProto +import android.car.cluster.navigation.road +import android.car.cluster.navigation.step +import android.car.cluster.navigation.timestamp +import android.car.navigation.CarNavigationStatusManager +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarText +import androidx.car.app.model.Distance +import androidx.car.app.navigation.NavigationManager +import androidx.car.app.navigation.model.Lane +import androidx.car.app.navigation.model.LaneDirection +import androidx.car.app.navigation.model.Maneuver +import androidx.car.app.navigation.model.TravelEstimate +import androidx.car.app.navigation.model.Trip +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap +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.view.common.DateTimeUtils +import com.android.car.libraries.apphost.view.common.DistanceUtils +import com.android.car.libraries.apphost.view.common.ImageUtils +import com.android.car.libraries.apphost.view.common.ImageViewParams +import java.io.ByteArrayOutputStream +import java.time.Duration +import java.util.UUID +import kotlinx.coroutines.coroutineScope + +/** + * Convert a [Trip] (from [NavigationManager]) to a [NavigationState.NavigationStateProto] that the + * Cluster can parse and display (via [CarNavigationStatusManager.sendNavigationStateChange]). + */ +class NavigationStateConverterImpl(private val templateContext: TemplateContext) : + NavigationStateConverter { + + /** If the Provider gave an error once, we don't want to keep hitting it */ + private var skipIcons = false + + override suspend fun tripToNavigationState(trip: Trip) = coroutineScope { + navigationStateProto { + serviceStatus = if (trip.isLoading) REROUTING else NORMAL + trip.currentRoad?.let { currentRoad = road { name = it.toString() } } + steps += trip.getNavigationStateSteps() + destinations += trip.getNavigationStateDestinations() + } + } + + private fun Trip.getNavigationStateDestinations(): List<NavigationState.Destination> = + destinations.zip(destinationTravelEstimates).map { (destination, estimate) -> + destination { + destination.name?.toString()?.let { title = it } + destination.address?.toString()?.let { address = it } + distance = estimate.toNavStateDistance() + estimate.arrivalTimeAtDestination?.timeSinceEpochMillis?.let { epochMillis -> + estimatedTimeAtArrival = timestamp { seconds = epochMillis / 1000 } + } + formattedDurationUntilArrival = estimate.getFormattedRemainingDuration(templateContext) + estimate.arrivalTimeAtDestination?.zoneShortName?.let { zoneId = it } + } + } + + private fun Trip.getNavigationStateSteps(): List<NavigationState.Step> { + return steps.zip(stepTravelEstimates).map { (step, estimate) -> + step { + step.maneuver?.let { maneuver = it.toNavStateManeuver() } + distance = estimate.toNavStateDistance() + step.cue?.let { cue = it.toNavStateCue() } + lanes += step.lanes.map { it.toNavStateLane() } + step.lanesImage?.toImageReference()?.let { lanesImage = it } + } + } + } + + private fun Lane.toNavStateLane() = lane { + laneDirections += + directions.map { laneDirection -> + laneDirection { + shape = laneDirection.shape.toNavStateShape() + isHighlighted = laneDirection.isRecommended + } + } + } + + private fun Int.toNavStateShape() = + when (this) { + LaneDirection.SHAPE_UNKNOWN -> Shape.UNKNOWN + LaneDirection.SHAPE_STRAIGHT -> Shape.STRAIGHT + LaneDirection.SHAPE_SLIGHT_LEFT -> Shape.SLIGHT_LEFT + LaneDirection.SHAPE_SLIGHT_RIGHT -> Shape.SLIGHT_RIGHT + LaneDirection.SHAPE_NORMAL_LEFT -> Shape.NORMAL_LEFT + LaneDirection.SHAPE_NORMAL_RIGHT -> Shape.NORMAL_RIGHT + LaneDirection.SHAPE_SHARP_LEFT -> Shape.SHARP_LEFT + LaneDirection.SHAPE_SHARP_RIGHT -> Shape.SHARP_RIGHT + else -> Shape.UNRECOGNIZED + } + + private fun CarText.toNavStateCue() = cue { + val cueText = this@toNavStateCue.toString() + alternateText = cueText + elements += cueElement { text = cueText } + } + + private fun TravelEstimate.toNavStateDistance() = distance { + meters = DistanceUtils.getMeters(remainingDistance) + remainingDistance?.let { + displayValue = DistanceUtils.convertDistanceToDisplayStringNoUnit(templateContext, it) + } + displayUnits = + when (remainingDistance?.displayUnit) { + Distance.UNIT_METERS -> NavigationState.Distance.Unit.METERS + Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> + NavigationState.Distance.Unit.KILOMETERS + Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> NavigationState.Distance.Unit.MILES + Distance.UNIT_FEET -> NavigationState.Distance.Unit.FEET + Distance.UNIT_YARDS -> NavigationState.Distance.Unit.YARDS + else -> NavigationState.Distance.Unit.UNKNOWN + } + } + + /** + * Only Resource/Bitmap icons are supported. Will return [null] for all other types of [CarIcon] + */ + private fun CarIcon.toImageReference(): NavigationState.ImageReference? { + if (skipIcons) return null + + val iconId = hash(this).toString() + + // Don't extract a Drawable unless needed + ClusterIconContentProvider.queryIconData(iconId, templateContext)?.let { + (contentUri, aspectRatio) -> + return NavigationState.ImageReference.newBuilder() + .setContentUri(contentUri) + .setAspectRatio(aspectRatio) + .build() + } + + // No cache for icon, get Drawable and cache it + val drawable = + ImageUtils.getIconDrawable(templateContext, this, ImageViewParams.DEFAULT) + ?: run { + L.d(LogTags.NAVIGATION) { + "Couldn't obtain Drawable from CarIcon (uri icons not supported): $this" + } + return null + } + + val aspectRatio = + if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) { + drawable.intrinsicWidth.toDouble() / drawable.intrinsicHeight.toDouble() + } else { + L.w(LogTags.NAVIGATION) { + "Drawable has no intrinsic dimensions aspect ratio. carIcon=$this" + } + return null + } + + val contentUri = + runCatching { + val bytes = drawable.toByteArray() + ClusterIconContentProvider.addToCache(iconId, bytes, templateContext) + } + .onFailure { + skipIcons = true + L.w(LogTags.NAVIGATION, it) { + "Failed to cache icon in provider." + + " Disabling cluster icons for ${templateContext.appPackageName}" + } + } + .getOrNull() + ?.toString() + ?: return null + + return NavigationState.ImageReference.newBuilder() + .setContentUri(contentUri) + .setAspectRatio(aspectRatio) + .build() + } + + private fun Drawable.toByteArray(): ByteArray { + val bitmap = this.toBitmap() + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + return stream.toByteArray() + } + + private fun hash(icon: CarIcon): Int { + return when (icon.icon?.type) { + IconCompat.TYPE_RESOURCE, IconCompat.TYPE_URI, null -> icon.hashCode() + else -> { + // For any iconCompat type that exists and isn't URI or Resource, we don't really + // know how to tell if two instances represent the same set of pixels. + // So we just consider them unique. + UUID.randomUUID().hashCode() + } + } + } + + private fun Maneuver.toNavStateManeuver() = maneuver { + val maneuver = this@toNavStateManeuver + type = maneuver.getNavStateType() + roundaboutExitNumber = maneuver.roundaboutExitNumber + maneuver.icon?.toImageReference()?.let { icon = it } + } + + private fun Maneuver.getNavStateType(): ManeuverType = + when (type) { + Maneuver.TYPE_UNKNOWN -> ManeuverType.UNKNOWN + Maneuver.TYPE_DEPART -> ManeuverType.DEPART + Maneuver.TYPE_NAME_CHANGE -> ManeuverType.NAME_CHANGE + Maneuver.TYPE_KEEP_LEFT -> ManeuverType.KEEP_LEFT + Maneuver.TYPE_KEEP_RIGHT -> ManeuverType.KEEP_RIGHT + Maneuver.TYPE_TURN_SLIGHT_LEFT -> ManeuverType.TURN_SLIGHT_LEFT + Maneuver.TYPE_TURN_SLIGHT_RIGHT -> ManeuverType.TURN_SLIGHT_RIGHT + Maneuver.TYPE_TURN_NORMAL_LEFT -> ManeuverType.TURN_NORMAL_LEFT + Maneuver.TYPE_TURN_NORMAL_RIGHT -> ManeuverType.TURN_NORMAL_RIGHT + Maneuver.TYPE_TURN_SHARP_LEFT -> ManeuverType.TURN_SHARP_LEFT + Maneuver.TYPE_TURN_SHARP_RIGHT -> ManeuverType.TURN_SHARP_RIGHT + Maneuver.TYPE_U_TURN_LEFT -> ManeuverType.U_TURN_LEFT + Maneuver.TYPE_U_TURN_RIGHT -> ManeuverType.U_TURN_RIGHT + Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT -> ManeuverType.ON_RAMP_SLIGHT_LEFT + Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT -> ManeuverType.ON_RAMP_SLIGHT_RIGHT + Maneuver.TYPE_ON_RAMP_NORMAL_LEFT -> ManeuverType.ON_RAMP_NORMAL_LEFT + Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT -> ManeuverType.ON_RAMP_NORMAL_RIGHT + Maneuver.TYPE_ON_RAMP_SHARP_LEFT -> ManeuverType.ON_RAMP_SHARP_LEFT + Maneuver.TYPE_ON_RAMP_SHARP_RIGHT -> ManeuverType.ON_RAMP_SHARP_RIGHT + Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> ManeuverType.ON_RAMP_U_TURN_LEFT + Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> ManeuverType.ON_RAMP_U_TURN_RIGHT + Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT -> ManeuverType.OFF_RAMP_SLIGHT_LEFT + Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT -> ManeuverType.OFF_RAMP_SLIGHT_RIGHT + Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> ManeuverType.OFF_RAMP_NORMAL_LEFT + Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> ManeuverType.OFF_RAMP_NORMAL_RIGHT + Maneuver.TYPE_FORK_LEFT -> ManeuverType.FORK_LEFT + Maneuver.TYPE_FORK_RIGHT -> ManeuverType.FORK_RIGHT + Maneuver.TYPE_MERGE_LEFT -> ManeuverType.MERGE_LEFT + Maneuver.TYPE_MERGE_RIGHT -> ManeuverType.MERGE_RIGHT + Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> ManeuverType.MERGE_SIDE_UNSPECIFIED + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE -> { + when (roundaboutExitAngle) { + in 6..45 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_SHARP_LEFT + in 46..135 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_NORMAL_LEFT + in 136..170 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_SLIGHT_LEFT + in 171..189 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_STRAIGHT + in 190..224 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_SLIGHT_RIGHT + in 225..314 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_NORMAL_RIGHT + in 315..354 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_SHARP_RIGHT + in 1..5, in 355..360 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW_U_TURN + else -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW + } + } + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> { + when (roundaboutExitAngle) { + in 6..45 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_SHARP_RIGHT + in 46..135 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_NORMAL_RIGHT + in 136..170 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_SLIGHT_RIGHT + in 171..189 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_STRAIGHT + in 190..224 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_SLIGHT_LEFT + in 225..314 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_NORMAL_LEFT + in 315..354 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_SHARP_LEFT + in 1..5, in 355..360 -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW_U_TURN + else -> ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW + } + } + Maneuver.TYPE_STRAIGHT -> ManeuverType.STRAIGHT + Maneuver.TYPE_FERRY_BOAT -> ManeuverType.FERRY_BOAT + Maneuver.TYPE_FERRY_TRAIN -> ManeuverType.FERRY_TRAIN + Maneuver.TYPE_DESTINATION -> ManeuverType.DESTINATION + Maneuver.TYPE_DESTINATION_STRAIGHT -> ManeuverType.DESTINATION_STRAIGHT + Maneuver.TYPE_DESTINATION_LEFT -> ManeuverType.DESTINATION_LEFT + Maneuver.TYPE_DESTINATION_RIGHT -> ManeuverType.DESTINATION_RIGHT + Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CW -> + ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CW + Maneuver.TYPE_ROUNDABOUT_ENTER_CCW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> + ManeuverType.ROUNDABOUT_ENTER_AND_EXIT_CCW + Maneuver.TYPE_FERRY_BOAT_LEFT, + Maneuver.TYPE_FERRY_BOAT_RIGHT, + Maneuver.TYPE_FERRY_TRAIN_LEFT, + Maneuver.TYPE_FERRY_TRAIN_RIGHT -> ManeuverType.FERRY_TRAIN + else -> ManeuverType.UNKNOWN + } + + private fun TravelEstimate.getFormattedRemainingDuration(templateContext: TemplateContext) = + if (remainingTimeSeconds == TravelEstimate.REMAINING_TIME_UNKNOWN) "" + else + DateTimeUtils.formatDurationString(templateContext, Duration.ofSeconds(remainingTimeSeconds)) +} + +/** Just a convenience to get the Client package name */ +private val TemplateContext.appPackageName + get() = carAppPackageInfo.componentName.packageName diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ProxyInputConnection.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ProxyInputConnection.kt new file mode 100644 index 0000000..a7f0b59 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ProxyInputConnection.kt @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.inputmethod.CompletionInfo +import android.view.inputmethod.CorrectionInfo +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.ExtractedText +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import androidx.car.app.activity.renderer.IProxyInputConnection +import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * Proxies an [InputConnection] across a binder interface. All InputConnection calls are made on the + * main thread. + * + * Please note that once an InputConnection is invalid, it never becomes valid again. An invalid + * InputConnection simply ignores calls that are made to it. + * + * Some InputConnection methods simply return a boolean indicating whether the input connection is + * still valid. For these methods, we run the action and update the validity of the input connection + * asynchronously - there's no need to synchronize this so long as the action happens on the main + * thread. For all other methods where the return value matters, we block on the Binder thread until + * the value has been provided on the main thread. + */ +class ProxyInputConnection( + private val inputConnection: InputConnection, + private val editorInfo: EditorInfo +) : IProxyInputConnection.Stub() { + @Volatile private var inputConnectionValid = true + private val handler = Handler(Looper.getMainLooper()) + + override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence? { + return runOnMainAndAwaitResult(null) { inputConnection.getTextBeforeCursor(n, flags) } + } + + override fun getTextAfterCursor(n: Int, flags: Int): CharSequence? { + return runOnMainAndAwaitResult(null) { inputConnection.getTextAfterCursor(n, flags) } + } + + override fun getSelectedText(flags: Int): CharSequence? { + return runOnMainAndAwaitResult(null) { inputConnection.getSelectedText(flags) } + } + + override fun getCursorCapsMode(reqModes: Int): Int { + return runOnMainAndAwaitResult(0) { inputConnection.getCursorCapsMode(reqModes) } + } + + override fun beginBatchEdit(): Boolean { + return runOnMainAndAwaitResult(false) { inputConnection.beginBatchEdit() } + } + + override fun endBatchEdit(): Boolean { + return runOnMainAndAwaitResult(false) { inputConnection.endBatchEdit() } + } + + override fun sendKeyEvent(event: KeyEvent): Boolean { + return runOnMainAndAwaitResult(false) { inputConnection.sendKeyEvent(event) } + } + + override fun commitCorrection(correctionInfo: CorrectionInfo): Boolean { + return runOnMainAndAwaitResult(false) { inputConnection.commitCorrection(correctionInfo) } + } + + override fun commitCompletion(text: CompletionInfo?): Boolean { + return runOnMainAndAwaitResult(false) { inputConnection.commitCompletion(text) } + } + + override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? { + return runOnMainAndAwaitResult(null) { inputConnection.getExtractedText(request, flags) } + } + + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + return runOnMainAndUpdateValidity { + inputConnection.deleteSurroundingText(beforeLength, afterLength) + } + } + + override fun setComposingText(text: CharSequence, newCursorPosition: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.setComposingText(text, newCursorPosition) } + } + + override fun setComposingRegion(start: Int, end: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.setComposingRegion(start, end) } + } + + override fun finishComposingText(): Boolean { + return runOnMainAndUpdateValidity { inputConnection.finishComposingText() } + } + + override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.commitText(text, newCursorPosition) } + } + + override fun setSelection(start: Int, end: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.setSelection(start, end) } + } + + override fun performEditorAction(editorAction: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.performEditorAction(editorAction) } + } + + override fun performContextMenuAction(id: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.performContextMenuAction(id) } + } + + override fun clearMetaKeyStates(states: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.clearMetaKeyStates(states) } + } + + override fun reportFullscreenMode(enabled: Boolean): Boolean { + return runOnMainAndUpdateValidity { inputConnection.reportFullscreenMode(enabled) } + } + + override fun performPrivateCommand(action: String, data: Bundle): Boolean { + return runOnMainAndUpdateValidity { inputConnection.performPrivateCommand(action, data) } + } + + override fun requestCursorUpdates(cursorUpdateMode: Int): Boolean { + return runOnMainAndUpdateValidity { inputConnection.requestCursorUpdates(cursorUpdateMode) } + } + + override fun closeConnection() { + runOnMainDirect { + inputConnection.closeConnection() + inputConnectionValid = false + } + } + + override fun getEditorInfo(): EditorInfo { + return editorInfo + } + + /** + * Runs code on the main thread, and blocks for the result on another. + * + * @param defaultResult the value to return if event timeout or if the connection is invalid. + * @param action the code to execute, that should return a result. + * @return the value produced by [action]. + */ + private fun <T> runOnMainAndAwaitResult(defaultResult: T, action: Callable<T>): T { + if (!inputConnectionValid) { + return defaultResult + } + if (Looper.myLooper() == Looper.getMainLooper()) { + return try { + action.call() + } catch (e: Exception) { + throw RuntimeException(e) + } + } + val futureTask = FutureTask(action) + handler.post(futureTask) + return try { + futureTask[ASYNC_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS] + } catch (e: ExecutionException) { + throw RuntimeException(e) + } catch (e: InterruptedException) { + defaultResult + } catch (e: TimeoutException) { + defaultResult + } + } + + /** + * Runs code on the main thread, and updates the [inputConnectionValid] with the result. + * + * @param action the code to execute, that should return a boolean indicating the validity of the + * connection. + * @return the value produced by [action] + */ + private fun runOnMainAndUpdateValidity(action: Callable<Boolean>): Boolean { + if (!inputConnectionValid) { + return false + } + + runOnMainDirect { + try { + inputConnectionValid = action.call() + } catch (ex: Exception) { + inputConnectionValid = false + throw RuntimeException("Input connection action failed", ex) + } + } + + return true + } + + /** + * Runs code on the main thread. Does not jump thread if already on the main thread. + * + * @param action the code to execute. + */ + private fun runOnMainDirect(action: Runnable) { + if (Looper.myLooper() == handler.looper) { + action.run() + } else { + handler.post(action) + } + } + + companion object { + private const val ASYNC_TIMEOUT_MILLIS: Long = 1000 + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/RendererCallback.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/RendererCallback.kt new file mode 100644 index 0000000..305ad38 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/RendererCallback.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.os.Handler +import android.os.Looper +import android.view.inputmethod.EditorInfo +import androidx.car.app.CarContext +import androidx.car.app.activity.renderer.IProxyInputConnection +import androidx.car.app.activity.renderer.IRendererCallback +import androidx.car.app.utils.ThreadUtils +import androidx.lifecycle.Lifecycle +import com.android.car.libraries.apphost.CarHost +import com.android.car.libraries.apphost.logging.L +import com.android.car.libraries.apphost.logging.LogTags +import com.android.car.libraries.apphost.template.AppHost +import java.util.concurrent.Callable +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** Handles events from the car app. */ +class RendererCallback(private val carHost: CarHost, private val inputManager: InputManagerImpl) : + IRendererCallback.Stub() { + private val handler = Handler(Looper.getMainLooper()) + + override fun onBackPressed() { + val appHost = carHost.getHostOrThrow(CarContext.APP_SERVICE) as? AppHost + appHost?.onBackPressed() + } + + override fun onCreate() { + ThreadUtils.runOnMain { carHost.dispatchAppLifecycleEvent(Lifecycle.Event.ON_CREATE) } + } + + override fun onStart() { + ThreadUtils.runOnMain { carHost.dispatchAppLifecycleEvent(Lifecycle.Event.ON_START) } + } + + override fun onResume() { + ThreadUtils.runOnMain { carHost.dispatchAppLifecycleEvent(Lifecycle.Event.ON_RESUME) } + } + + override fun onPause() { + ThreadUtils.runOnMain { + try { + carHost.dispatchAppLifecycleEvent(Lifecycle.Event.ON_PAUSE) + } catch (e: IllegalStateException) { + // Don't crash when dispatching on shutdown as you can run into race conditions. + } + } + } + + override fun onStop() { + ThreadUtils.runOnMain { + try { + carHost.dispatchAppLifecycleEvent(Lifecycle.Event.ON_STOP) + } catch (e: IllegalStateException) { + // Don't crash when dispatching on shutdown as you can run into race conditions. + } + } + } + + override fun onDestroyed() { + // Unlike the other lifecycle events, the fact that the CarAppActivity is destroyed does + // not mean that the CarAppBinding should be destroyed or unbound. We already have logic + // in CarHost to unbind the CarAppService after a specific timeout if the app remains in the + // STOPPED state (for non-nav apps). + } + + override fun onCreateInputConnection(editorInfo: EditorInfo): IProxyInputConnection? { + return runOnMainAndAwaitResult { inputManager.onCreateInputConnection(editorInfo) } + } + + /** + * Runs code on the main thread, and waits for the result. + * + * @param action the code to execute, that should return a result. + * @return the value produced by [action]. Returns null if times out or interrupted. + */ + private fun <T> runOnMainAndAwaitResult(action: Callable<T>): T? { + if (Looper.myLooper() == Looper.getMainLooper()) { + return try { + action.call() + } catch (e: Exception) { + throw RuntimeException(e) + } + } + val futureTask = FutureTask(action) + handler.post(futureTask) + return try { + futureTask[ASYNC_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS] + } catch (e: ExecutionException) { + throw RuntimeException(e) + } catch (e: InterruptedException) { + L.e(LogTags.APP_HOST, e, "Running call on main was interrupted.") + null + } catch (e: TimeoutException) { + L.e(LogTags.APP_HOST, e, "Running call on main was timed out.") + null + } + } + + companion object { + private const val ASYNC_TIMEOUT_MILLIS: Long = 1000 + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/RoutingInfoStateImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/RoutingInfoStateImpl.kt new file mode 100644 index 0000000..f696426 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/RoutingInfoStateImpl.kt @@ -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.templates.host.internal + +import com.android.car.libraries.apphost.common.RoutingInfoState + +/** Manages the state of routing information in template apps. */ +class RoutingInfoStateImpl : RoutingInfoState { + private var isVisible = false + + override fun setIsRoutingInfoVisible(isVisible: Boolean) { + this.isVisible = isVisible + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/StartCarAppUtil.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/StartCarAppUtil.kt new file mode 100644 index 0000000..5f9bf8b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/StartCarAppUtil.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.content.Context +import android.content.Intent +import androidx.annotation.VisibleForTesting +import androidx.car.app.CarContext +import androidx.car.app.activity.CarAppActivity +import com.android.car.libraries.apphost.NavigationIntentConverter +import com.google.common.base.Objects.equal +import java.lang.UnsupportedOperationException +import java.security.InvalidParameterException + +/** An utility class to validate calls to start a car app, and to perform them. */ +object StartCarAppUtil { + private const val PHONE_URI_PREFIX = "tel:" + + /** + * Metadata tag that points to the component name of the car app service that is linked to the car + * app service. + */ + @VisibleForTesting const val ACTIVITY_METADATA_KEY = "androidx.car.app.CAR_APP_ACTIVITY" + + /** + * Asserts that the `intent` follows the guidelines set in [CarContext.startCarApp] and starts the + * app. + * + * @param packageName the package name of the app that sent the intent + * @param intent the intent for starting the car app. + * @param allowedToStartSelf whether the calling app is allowed to start itself. Only nav apps + * ``` + * can call via [CarContext.startCarApp], and all apps can via a + * notification action. + * @throws SecurityException + * ``` + * if the app attempts to start a different app explicitly or + * ``` + * does not have permissions for the requested action. + * @throws InvalidParameterException + * ``` + * if the [Intent] does not meet the criteria listed at + * ``` + * [CarContext.startCarApp]. + * ``` + */ + fun validateStartCarAppIntent( + context: Context, + packageName: String, + intent: Intent, + allowedToStartSelf: Boolean + ): Intent { + val intentComponent = intent.component + val action = intent.action + + if (intentComponent != null && equal(intentComponent.packageName, packageName)) { + if (!allowedToStartSelf) { + throw SecurityException( + "The app is not a turn by turn navigation app, therefore it cannot start " + + "itself in the car" + ) + } + intent.setClassName(packageName, CarAppActivity::class.qualifiedName!!) + } else if (equal(action, CarContext.ACTION_NAVIGATE)) { + assertNavigationIntentIsValid(intent) + + // TODO(b/171308515): Add telemetry support. + } else if (equal(action, Intent.ACTION_DIAL) || equal(action, Intent.ACTION_CALL)) { + assertPhoneIntentIsValid(intent) + + // TODO(b/171308515): Add telemetry support. + } else if (intentComponent == null) { + throw InvalidParameterException("The intent is not for a supported action") + } else { + throw SecurityException("Explicitly starting a separate app is not supported") + } + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + intent.resolveActivity(context.packageManager) + ?: throw UnsupportedOperationException( + "No component found to handle the startCarApp intent: $intent" + ) + + return intent + } + + /** + * Checks that the [Intent] is for a phone call by validating it meets the following: + * + * * The data is correctly formatted starting with "tel:"" + * * Has no component name set + */ + private fun assertPhoneIntentIsValid(intent: Intent) { + if (!intent.dataString.orEmpty().startsWith(PHONE_URI_PREFIX)) { + throw InvalidParameterException("Phone intent data is not properly formatted") + } + if (intent.component != null) { + throw SecurityException("Phone intent cannot have a component") + } + } + + /** + * Checks that the [Intent] is for navigation by validating it meets the following: + * + * * The data is formatted as described in [CarContext.startCarApp] + * * Has no component name set + */ + private fun assertNavigationIntentIsValid(intent: Intent) { + val uri = intent.data + if (uri == null || !equal(NavigationIntentConverter.GEO_QUERY_PREFIX, uri.scheme)) { + throw InvalidParameterException("Navigation intent has a malformed uri") + } + + val queryString = NavigationIntentConverter.getQueryString(uri) + if (queryString == null) { + if (NavigationIntentConverter.getCarLocation(uri) == null) { + throw InvalidParameterException( + "Navigation intent has neither a location nor a query string" + ) + } + } else { + if (uri.encodedSchemeSpecificPart.contains("daddr=")) { + // Other intent URIs support daddr, we do not as of right now. + throw InvalidParameterException( + "Navigation intent has neither latitude,longitude nor a query string" + ) + } + } + if (intent.component != null) { + throw SecurityException("Navigation intent cannot have a component") + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/StatusManager.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/StatusManager.kt new file mode 100644 index 0000000..563ddde --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/StatusManager.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import androidx.annotation.VisibleForTesting +import com.android.car.libraries.apphost.logging.StatusReporter +import java.io.PrintWriter +import java.util.SortedMap +import java.util.TreeMap + +/** Manager to handle collecting status information from components to be added to a bug report. */ +object StatusManager : StatusReporter { + /** Sections to include in the status information */ + enum class ReportSection { + APP_HOST, + SCREEN_RENDERES, + } + + private val statusReporters: SortedMap<ReportSection, StatusReporter> = TreeMap() + private val lock = Any() + + /** + * Adds a [StatusReporter] to be called for a bug report. + * + * @param section The section to be added to the bug report. + * @param reporter The [StatusReporter] that will fill in the information for the section. + */ + fun addStatusReporter(section: ReportSection, reporter: StatusReporter) { + synchronized(lock) { statusReporters.put(section, reporter) } + } + + /** + * Removes the [StatusReporter] for a given bug report section. + * + * @param section The section to remove, as passed to [.addStatusReporter]. + */ + fun removeStatusReporter(section: ReportSection) { + synchronized(lock) { statusReporters.remove(section) } + } + + @VisibleForTesting + fun clear() { + synchronized(lock) { statusReporters.clear() } + } + + override fun reportStatus(writer: PrintWriter, piiHandling: StatusReporter.Pii) { + synchronized(lock) { + for ((key, value) in statusReporters) { + writer.format("=== %s ===\n", key.name) + try { + value.reportStatus(writer, piiHandling) + } catch (throwable: Throwable) { + writer.format("\nError capturing dump for section: %s\n", throwable.message) + throwable.printStackTrace(writer) + } + writer.println() + } + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/SurfaceInfoProviderImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/SurfaceInfoProviderImpl.kt new file mode 100644 index 0000000..d7b696c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/SurfaceInfoProviderImpl.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal + +import android.graphics.Rect +import com.android.car.libraries.apphost.common.EventManager +import com.android.car.libraries.apphost.common.EventManager.EventType +import com.android.car.libraries.apphost.common.SurfaceInfoProvider + +/** Provides surface properties necessary for efficiently rendering partial content. */ +// TODO (b/206636788): Consolidate redundant code and improve documentation +internal class SurfaceInfoProviderImpl(private val eventManager: EventManager) : + SurfaceInfoProvider { + private var visibleArea: Rect? = null + private var stableArea: Rect? = null + + override fun getVisibleArea() = visibleArea + override fun getStableArea() = stableArea + + override fun setVisibleArea(area: Rect) { + val currentAreaNeedUpdated = visibleArea == null || area != visibleArea + visibleArea = area + if (currentAreaNeedUpdated) { + eventManager.dispatchEvent(EventType.SURFACE_VISIBLE_AREA) + } + + val stableAreaToUpdate = calculateStableArea(area, stableArea) + if (stableArea != stableAreaToUpdate) { + stableArea = stableAreaToUpdate + eventManager.dispatchEvent(EventType.SURFACE_STABLE_AREA) + } + } + + private fun calculateStableArea(visibleArea: Rect, stableArea: Rect?): Rect { + return if (stableArea == null || !stableArea.setIntersect(stableArea, visibleArea)) { + visibleArea + } else { + stableArea + } + } + + override fun invalidateStableArea() { + stableArea = null + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/TemplateContextImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/TemplateContextImpl.kt new file mode 100644 index 0000000..c0430fb --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/TemplateContextImpl.kt @@ -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.templates.host.internal + +import android.content.ComponentName +import android.content.Context +import android.content.res.Configuration +import android.hardware.display.DisplayManager +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.BackPressedHandler +import com.android.car.libraries.apphost.common.CarAppError +import com.android.car.libraries.apphost.common.CarAppManager +import com.android.car.libraries.apphost.common.CarAppPackageInfo +import com.android.car.libraries.apphost.common.CarHostConfig +import com.android.car.libraries.apphost.common.ColorContrastCheckState +import com.android.car.libraries.apphost.common.ColorUtils +import com.android.car.libraries.apphost.common.DebugOverlayHandler +import com.android.car.libraries.apphost.common.ErrorHandler +import com.android.car.libraries.apphost.common.EventManager +import com.android.car.libraries.apphost.common.EventManager.EventType +import com.android.car.libraries.apphost.common.HostResourceIds +import com.android.car.libraries.apphost.common.RoutingInfoState +import com.android.car.libraries.apphost.common.StatusBarManager +import com.android.car.libraries.apphost.common.SurfaceCallbackHandler +import com.android.car.libraries.apphost.common.SurfaceInfoProvider +import com.android.car.libraries.apphost.common.SystemClockWrapper +import com.android.car.libraries.apphost.common.TemplateContext +import com.android.car.libraries.apphost.common.ToastController +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.internal.ANRHandlerImpl +import com.android.car.libraries.apphost.internal.AppDispatcherImpl +import com.android.car.libraries.apphost.internal.CarAppPackageInfoImpl +import com.android.car.libraries.apphost.logging.TelemetryHandler +import com.android.car.libraries.templates.host.di.FeaturesConfig +import com.android.car.libraries.templates.host.di.HostApiLevelConfig +import com.android.car.libraries.templates.host.di.ThemeManager +import com.android.car.libraries.templates.host.di.UxreConfig +import java.io.PrintWriter + +/** A [TemplateContext] to provide to hosts and presenters. */ +class TemplateContextImpl +private constructor( + context: Context, + appName: ComponentName, + private val backPressedHandler: BackPressedHandler, + private val surfaceCallbackHandler: SurfaceCallbackHandler, + private val statusBarManager: StatusBarManager, + errorHandler: ErrorHandler, + private val toastController: ToastController, + private val displayId: Int, + private val inputManager: InputManager, + private val inputConfig: InputConfig, + private val carAppManager: CarAppManager, + private val telemetryHandler: TelemetryHandler, + private val debugOverlayHandler: DebugOverlayHandler, + private val routingInfoState: RoutingInfoState, + private val colorContrastCheckState: ColorContrastCheckState, + private val carHostConfig: CarHostConfig, + private val systemClockWrapper: SystemClockWrapper, + isNavigationApp: Boolean, + private val hostResourceIds: HostResourceIds, + uxreConfig: UxreConfig, + themeManager: ThemeManager +) : TemplateContext(context) { + + private val carAppPackageInfo: CarAppPackageInfo = + CarAppPackageInfoImpl.create( + context, + appName, + isNavigationApp, + hostResourceIds, + AppIconLoaderImpl + ) + private val eventManager: EventManager + private val surfaceInfoProvider: SurfaceInfoProvider + private val appDispatcher: AppDispatcher + private var appConfigurationContext: Context? = null + private var errorHandler: ErrorHandler + private val anrHandler: ANRHandler + private val constraintsProvider: ConstraintsProvider + private var lastError: CarAppError? = null + private val appBindingStateProvider: AppBindingStateProvider + + init { + this.errorHandler = + ErrorHandler { error -> + lastError = error + errorHandler.showError(error) + } + + themeManager.applyTheme(context) + + appBindingStateProvider = AppBindingStateProvider() + eventManager = EventManager() + anrHandler = ANRHandlerImpl.create(appName, errorHandler, telemetryHandler, eventManager) + constraintsProvider = ConstraintsProviderImpl(context, eventManager, uxreConfig) + surfaceInfoProvider = SurfaceInfoProviderImpl(eventManager) + appDispatcher = + AppDispatcherImpl.create( + appName, + errorHandler, + anrHandler, + telemetryHandler, + appBindingStateProvider + ) + + // Create a context configured with this context's configuration, the car display's display + // metrics, and the remote app's theme. + val packageContext = ColorUtils.getPackageContext(context, appName.packageName) + if (packageContext == null) { + appConfigurationContext = null + } else { + val configuration = resources.configuration + val display = context.getSystemService(DisplayManager::class.java).getDisplay(displayId) + appConfigurationContext = + packageContext.createDisplayContext(display).createConfigurationContext(configuration) + appConfigurationContext?.setTheme(ColorUtils.loadThemeId(context, appName)) + } + } + + override fun getErrorHandler() = errorHandler + override fun getAppConfigurationContext() = appConfigurationContext + override fun getStatusBarManager() = statusBarManager + override fun getInputManager() = inputManager + override fun getInputConfig() = inputConfig + override fun getCarAppPackageInfo() = carAppPackageInfo + override fun getBackPressedHandler() = backPressedHandler + override fun getSurfaceCallbackHandler() = surfaceCallbackHandler + override fun getSurfaceInfoProvider() = surfaceInfoProvider + override fun getEventManager() = eventManager + override fun getAnrHandler() = anrHandler + override fun getAppDispatcher() = appDispatcher + override fun getToastController() = toastController + override fun getCarAppManager() = carAppManager + override fun getTelemetryHandler() = telemetryHandler + override fun getDebugOverlayHandler() = debugOverlayHandler + override fun getHostResourceIds() = hostResourceIds + override fun getRoutingInfoState() = routingInfoState + override fun getColorContrastCheckState() = colorContrastCheckState + override fun getConstraintsProvider() = constraintsProvider + override fun getCarHostConfig() = carHostConfig + override fun getSystemClockWrapper() = systemClockWrapper + override fun getAppBindingStateProvider() = appBindingStateProvider + + override fun updateConfiguration(configuration: Configuration?) { + appConfigurationContext = + configuration?.let { appConfigurationContext?.createConfigurationContext(configuration) } + + // Propagate the configuration changed event to any listeners. + getEventManager().dispatchEvent(EventType.CONFIGURATION_CHANGED) + } + + override fun reportStatus(pw: PrintWriter) { + pw.printf("- app package info: %s\n", carAppPackageInfo) + pw.printf("- last error: %s\n", if (lastError != null) lastError else "n/a") + } + + companion object { + /** Creates a [TemplateContextImpl] for the car app identified by the given [ComponentName] */ + fun create( + context: Context, + appName: ComponentName, + displayId: Int, + backPressedHandler: BackPressedHandler, + surfaceCallbackHandler: SurfaceCallbackHandler, + statusBarManager: StatusBarManager, + debugOverlayHandler: DebugOverlayHandler, + inputManager: InputManager, + inputConfig: InputConfig, + carAppManager: CarAppManager, + isNavigationApp: Boolean, + hostResourceIds: HostResourceIds, + uxreConfig: UxreConfig, + hostApiLevelConfig: HostApiLevelConfig, + themeManager: ThemeManager, + telemetryHandler: TelemetryHandler, + featuresConfig: FeaturesConfig + ): TemplateContextImpl { + return TemplateContextImpl( + context, + appName, + backPressedHandler, + surfaceCallbackHandler, + statusBarManager, + ErrorHandlerImpl.create(context, appName, carAppManager, hostResourceIds), + ToastControllerImpl(context), + displayId, + inputManager, + inputConfig, + carAppManager, + telemetryHandler, + debugOverlayHandler, + RoutingInfoStateImpl(), + ColorContrastCheckStateImpl(), + CarHostConfigImpl(context, appName, hostApiLevelConfig, featuresConfig), + SystemClockWrapper(), + isNavigationApp, + hostResourceIds, + uxreConfig, + themeManager + ) + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ToastControllerImpl.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ToastControllerImpl.kt new file mode 100644 index 0000000..57f0328 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/ToastControllerImpl.kt @@ -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.templates.host.internal + +import android.content.Context +import android.widget.Toast +import com.android.car.libraries.apphost.common.ToastController + +/** Manages the toasts on car screen. */ +class ToastControllerImpl(private val context: Context) : ToastController { + override fun showToast(text: CharSequence?, duration: Int) { + Toast.makeText(context, text, duration).show() + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/AndroidManifest.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/AndroidManifest.xml new file mode 100644 index 0000000..16dafa5 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/AndroidManifest.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.car.libraries.templates.host.internal.debug"> + + <uses-sdk android:minSdkVersion="29" android:targetSdkVersion="31" /> + + <application> + <activity + android:name=".ClusterActivity" + android:allowEmbedded="true" + android:excludeFromRecents="true" + android:exported="true" + android:launchMode="singleInstance" + android:process=":renderer_service" + android:resizeableActivity="true" + android:screenOrientation="user" + android:theme="@style/Theme.Template"> + <!-- In car_embedded builds, indicate that we are distraction optimized to prevent maps + from being killed when the car is moving. --> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.car.cluster.NAVIGATION" /> + </intent-filter> + + <meta-data + android:name="distractionOptimized" + android:value="true" /> + </activity> + </application> +</manifest> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/ClusterActivity.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/ClusterActivity.kt new file mode 100644 index 0000000..c5a082c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/ClusterActivity.kt @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.internal.debug + +import android.annotation.SuppressLint +import android.car.Car +import android.content.Intent +import android.graphics.Color +import android.graphics.Rect +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import android.view.View +import android.view.ViewGroup +import androidx.annotation.StyleableRes +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +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.CarTextParams +import com.android.car.libraries.templates.host.internal.HostNavState +import com.android.car.libraries.templates.host.internal.NavigationCoordinator +import com.android.car.libraries.templates.host.view.widgets.navigation.CompactStepView +import com.android.car.libraries.templates.host.view.widgets.navigation.DetailedStepView +import com.android.car.libraries.templates.host.view.widgets.navigation.ProgressView +import com.android.car.libraries.templates.host.view.widgets.navigation.TravelEstimateView +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import com.android.car.libraries.templates.host.R + +/** + * This activity will be launched by the system to show the user navigation updates in the + * instrument cluster. + */ +class ClusterActivity : AppCompatActivity() { + private lateinit var root: ViewGroup + + // travel estimate card will only be enabled if there's enough room on screen + private var travelEstimateEnabled = false + private var travelEstimateView: TravelEstimateView? = null + private var travelEstimateContainer: ViewGroup? = null + private lateinit var detailedStepView: DetailedStepView + private lateinit var compactStepView: CompactStepView + private lateinit var progressView: ProgressView + + private var carTextParams: CarTextParams? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.cluster_activity) + root = findViewById(R.id.root) + travelEstimateContainer = findViewById(R.id.travel_estimate_card_container) + travelEstimateView = findViewById(R.id.travel_estimate_view) + detailedStepView = findViewById(R.id.detailed_step_view) + compactStepView = findViewById(R.id.compact_step_view) + progressView = findViewById(R.id.progress_view) + + initColors() + // `root` hasn't finished measuring yet, and will report width=0, so we need to throw this work + // to end of the MainLooper's queue. + Handler(Looper.getMainLooper()).post { + adjustViewport(intent) + calcTravelEstimateEnabled() + } + + observeNavigationState() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + adjustViewport(intent) + } + + private fun initColors() { + // Read the fallback color to use with the app-defined card background color. + @StyleableRes + val themeAttrs = + intArrayOf( + com.android.car.libraries.templates.host.R.attr.templateNavCardFallbackContentColor + ) + val ta = obtainStyledAttributes(themeAttrs) + val contentColor = ta.getColor(0, Color.WHITE) + ta.recycle() + detailedStepView.setTextColor(contentColor) + compactStepView.setTextColor(contentColor) + progressView.setColor(contentColor) + } + + private fun observeNavigationState() { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + NavigationCoordinator.getInstance(applicationContext).navigationState.collect { state -> + when (state) { + HostNavState.NotNavigating -> { + detailedStepView.setStepAndDistance(null, null, null, null, Color.TRANSPARENT, false) + compactStepView.setStep(null, null, null, Color.TRANSPARENT) + travelEstimateContainer?.visibility = View.GONE + } + is HostNavState.Navigating -> { + renderTrip(state) + } + } + } + } + } + } + + private fun renderTrip(state: HostNavState.Navigating) { + val trip = state.trip + val templateContext = state.templateContext + val step = trip.steps.firstOrNull() + val travelEstimate = trip.stepTravelEstimates.firstOrNull() + val nextStep = trip.steps.elementAtOrNull(1) + val carTextParams = + carTextParams ?: createStepTextParams(templateContext).also { carTextParams = it } + detailedStepView.setStepAndDistance( + templateContext, + step, + travelEstimate?.remainingDistance, + carTextParams, + Color.TRANSPARENT, + false + ) + compactStepView.setStep(templateContext, nextStep, carTextParams, Color.TRANSPARENT) + + progressView.visibility = if (trip.isLoading) View.VISIBLE else View.GONE + if (travelEstimateEnabled && travelEstimate != null) { + travelEstimateContainer?.visibility = View.VISIBLE + travelEstimateView?.setTravelEstimate(templateContext, travelEstimate) + } else { + travelEstimateContainer?.visibility = View.GONE + } + } + + /** + * Some of the display might be obscured by either the shape of the physical screen, or other + * elements in the cluster display. We need to respect this constraint and only display our UI + * within those bounds. + */ + private fun adjustViewport(intent: Intent?) { + intent ?: return + val bundle = intent.getBundleExtra(Car.CAR_EXTRA_CLUSTER_ACTIVITY_STATE) ?: return + val viewport = bundle.getParcelable<Rect>("android.car:activityState.unobscured") ?: return + + L.d(LogTags.CLUSTER) { "cluster un-obscured area: $viewport" } + root.setPadding( + viewport.left, + viewport.top, + root.width - viewport.right, + root.height - viewport.bottom + ) + } + + private fun calcTravelEstimateEnabled() { + val top = root.top + root.paddingTop + val bottom = root.bottom - root.paddingBottom + val safeAreaHeight = bottom - top + val threshold = + resources.getDimensionPixelSize(R.dimen.travel_estimate_card_min_height_threshold) + travelEstimateEnabled = safeAreaHeight > threshold + } + + /** + * Returns a [CarTextParams] instance to use for the text of a step. + * + * Unlike other text elsewhere, image spans are allowed in these strings. + */ + @SuppressLint("ResourceType") + private fun createStepTextParams(templateContext: TemplateContext): CarTextParams? { + @StyleableRes + val themeAttrs = + intArrayOf( + R.attr.templateRoutingImageSpanRatio, + R.attr.templateRoutingImageSpanBody2MaxHeight, + R.attr.templateRoutingImageSpanBody3MaxHeight + ) + val ta = templateContext.obtainStyledAttributes(themeAttrs) + val imageRatio = ta.getFloat(0, 0f) + val body2MaxHeight = ta.getDimensionPixelSize(1, 0) + ta.recycle() + val maxWidth = (body2MaxHeight * imageRatio).toInt() + return CarTextParams.builder() + .setImageBoundingBox(Rect(0, 0, maxWidth, body2MaxHeight)) + .setMaxImages(2) + .setColorSpanConstraints(CarColorConstraints.NO_COLOR) + .build() + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/layout/cluster_activity.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/layout/cluster_activity.xml new file mode 100644 index 0000000..46b2c01 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/layout/cluster_activity.xml @@ -0,0 +1,46 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/root" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <include + android:id="@+id/steps_card_container" + layout="@layout/steps_card_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="?templateCardContentContainerBottomMargin" + app:layout_constraintBottom_toTopOf="@id/travel_estimate_card_container" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_chainStyle="packed" /> + + <include + android:id="@+id/travel_estimate_card_container" + layout="@layout/travel_estimate_card_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/steps_card_container" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/styles/res/values/dimens.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/styles/res/values/dimens.xml new file mode 100644 index 0000000..234d34f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/styles/res/values/dimens.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- Travel estimate card should only be shown if the total safe area available for cluster is larger than this threshold --> +<!-- <dimen name="travel_estimate_card_min_height_threshold">400dp</dimen>--> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/dimens.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/dimens.xml new file mode 100644 index 0000000..bdee464 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/dimens.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <dimen name="car_app_ui_cluster_nav_icon_size">48dp</dimen> + <dimen name="car_app_ui_cluster_nav_text_size">40sp</dimen> + <!-- Travel estimate card should only be shown if the total safe area available for cluster is larger than this threshold --> + <dimen name="travel_estimate_card_min_height_threshold">400dp</dimen> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/strings.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/strings.xml new file mode 100644 index 0000000..248e56b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/strings.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- Cluster serves drivers. They don't need contentDescription. --> + <string name="dummy_content_description" translatable="false" /> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/styles.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/styles.xml new file mode 100644 index 0000000..a90d718 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/debug/res/values/styles.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <style name="Theme.AppCompat.NoActionBar.Fullscreen" parent="Theme.AppCompat.NoActionBar"> + <item name="android:windowNoTitle">true</item> + <item name="android:windowActionBar">false</item> + <item name="android:windowFullscreen">true</item> + <item name="android:windowContentOverlay">@null</item> + </style> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/res/values/bools.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/res/values/bools.xml new file mode 100644 index 0000000..8d949aa --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/res/values/bools.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- Whether or not NavState should be sent to NavigationManager --> + <bool name="send_navstates_to_system">true</bool> +</resources>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/res/values/integers.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/res/values/integers.xml new file mode 100644 index 0000000..93f023f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/internal/res/values/integers.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + + <!-- How long to wait before unbinding from the service after the user leaves an app. --> + <integer name="app_unbind_delay_seconds">180</integer> + + <!-- The max length of a car app list for showing routes. --> + <integer name="route_list_max_length">3</integer> + + <!-- The max length of a car app list for showing pane information. --> + <integer name="pane_max_length">4</integer> + + <!-- The max size for the template stack for the car app. --> + <integer name="template_stack_max_size">5</integer> + + <!-- Default max string length --> + <integer name="car_app_default_max_string_length">120</integer> + + <!-- How long to keep Cluster Icons in memory --> + <integer name="cluster_icon_cache_duration_millis">10000</integer> + + <!-- How long to wait for Trip conversion before giving up on this update --> + <integer name="cluster_trip_to_navstate_conversion_timeout_millis">1000</integer> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_action_button_background_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_action_button_background_color_selector.xml new file mode 100644 index 0000000..9707746 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_action_button_background_color_selector.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + https://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + <!-- Primary color --> + <item app:type_primary="true" + android:color="@color/car_app_ui_action_button_primary_background_color"/> + <!-- Default--> + <item android:color="@color/car_app_ui_action_button_default_background_color"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_background_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_background_color_selector.xml new file mode 100644 index 0000000..ee4c7a0 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_background_color_selector.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- Disabled state--> + <item android:state_enabled="false" android:color="@color/default_gray_928"/> + + <!-- Default--> + <item android:color="@color/default_gray_868"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_color_selector.xml new file mode 100644 index 0000000..e2e0aad --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_color_selector.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- Disabled state--> + <item android:state_enabled="false" android:color="@color/default_gradient_white_56"/> + + <!-- Default--> + <item android:color="@color/default_white"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_foreground_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_foreground_color_selector.xml new file mode 100644 index 0000000..fc38e92 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_foreground_color_selector.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <!-- Note: order of the following lines is important --> + <item app:state_error="true" android:color="@color/car_app_ui_edit_text_error_color"/> + <item android:state_focused="true" android:color="@color/car_app_ui_edit_text_active_color"/> + <item android:state_enabled="true" android:color="@color/car_app_ui_edit_text_enabled_color"/> + <item android:color="@color/car_app_ui_edit_text_disabled_color"/> +</selector>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_hint_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_hint_color_selector.xml new file mode 100644 index 0000000..d2e2056 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_edit_text_hint_color_selector.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- Disabled state--> + <item android:state_enabled="false" android:color="@color/default_gradient_white_56"/> + + <!-- Default--> + <item android:color="@color/default_gradient_white_72"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_ripple_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_ripple_color_selector.xml new file mode 100644 index 0000000..c6c3432 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/color/default_ripple_color_selector.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + https://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- Due to b/120995067 in order to keep normal ripple effect in touch, the color selector must show +for all states. In rotary and touchpad, per go/aa-focus-design, don't draw ripple when not pressed. +Doing so also avoids "ghost" effect when rapidly moving focus across Views. --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="@color/default_controller_ripple_selector_color"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_action_button_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_action_button_background.xml new file mode 100644 index 0000000..4aaee56 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_action_button_background.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- The background fill --> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/default_action_button_background_color_selector"/> + <corners + android:radius="@dimen/car_app_ui_button_corner_radius"/> + </shape> + </item> + + <!-- Masked ripple layer --> + <item android:drawable="@drawable/default_action_button_ripple"/> +</layer-list> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_action_button_ripple.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_action_button_ripple.xml new file mode 100644 index 0000000..ba31afc --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_action_button_ripple.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/default_ripple_color_selector"> + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item + android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/default_ripple_color_selector"/> + <corners android:radius="@dimen/car_app_ui_button_corner_radius"/> + </shape> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_edit_text_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_edit_text_background.xml new file mode 100644 index 0000000..e6b3670 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_edit_text_background.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/default_edit_text_background_color_selector"/> + <corners android:radius="@dimen/car_app_ui_corner_radius"/> + </shape> + </item> +</layer-list> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_edit_text_foreground.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_edit_text_foreground.xml new file mode 100644 index 0000000..4451f8d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_edit_text_foreground.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:gravity="bottom"> + <shape> + <size android:height="@dimen/car_app_ui_edit_text_border_width" /> + <solid android:color="@color/default_edit_text_foreground_color_selector" /> + </shape> + </item> +</layer-list> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_ic_pan_button.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_ic_pan_button.xml new file mode 100644 index 0000000..171d5c2 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_ic_pan_button.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M15.54,5.54L13.77,7.3 12,5.54 10.23,7.3 8.46,5.54 12,2zM18.46,15.54l-1.76,-1.77L18.46,12l-1.76,-1.77 1.76,-1.77L22,12zM8.46,18.46l1.77,-1.76L12,18.46l1.77,-1.76 1.77,1.76L12,22zM5.54,8.46l1.76,1.77L5.54,12l1.76,1.77 -1.76,1.77L2,12z"/> + <path + android:fillColor="@android:color/white" + android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_ic_refresh_button.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_ic_refresh_button.xml new file mode 100644 index 0000000..6384396 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/drawable/default_ic_refresh_button.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M12,20q-3.35,0 -5.675,-2.325Q4,15.35 4,12q0,-3.35 2.325,-5.675Q8.65,4 12,4q1.725,0 3.3,0.713 1.575,0.712 2.7,2.037V4h2v7h-7V9h4.2q-0.8,-1.4 -2.188,-2.2Q13.625,6 12,6 9.5,6 7.75,7.75T6,12q0,2.5 1.75,4.25T12,18q1.925,0 3.475,-1.1T17.65,14h2.1q-0.7,2.65 -2.85,4.325Q14.75,20 12,20z"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values-night/colors.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values-night/colors.xml new file mode 100644 index 0000000..24bad14 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values-night/colors.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + + <color name="default_hun_text_color">#E0FFFFFF</color> + <color name="default_hun_text_color2">#80FFFFFF</color> + + <color name="default_card_text_color">#CCFFFFFF</color> + <color name="default_card_background_color">@color/default_gray_868</color> + <color name="default_focus_blue">#2371CD</color> + +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values-night/colors_overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values-night/colors_overlayable.xml new file mode 100644 index 0000000..f7a1c51 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values-night/colors_overlayable.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + + <!-- Buttons. --> + <color name="car_app_ui_floating_button_default_background_color">@color/default_white</color> + <color name="car_app_ui_floating_button_default_text_color">@color/default_black</color> + +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/attrs.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/attrs.xml new file mode 100644 index 0000000..07b3c19 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/attrs.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- Custom error state to be used in edit boxes or other components that support this state --> +<!-- <declare-styleable name="ErrorState">--> +<!-- <attr name="state_error" format="boolean"/>--> +<!-- </declare-styleable>--> + + <!-- Custom button type to be used in action buttons or other component that support this + classification --> + <declare-styleable name="ButtonType"> + <!-- Indicates this a "primary" button, out of a set of other buttons --> + <attr name="type_primary" format="boolean"/> + <!-- Indicates this an app button, background color controlled by the app --> + <attr name="type_custom" format="boolean"/> + </declare-styleable> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/bools_overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/bools_overlayable.xml new file mode 100644 index 0000000..22e64fe --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/bools_overlayable.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Boolean definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these booleans directly from views. Booleans must be + referred to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <bool name="car_app_ui_customized">false</bool> + <bool name="car_app_ui_is_action_color_overridden">false</bool> + <bool name="car_app_ui_action_button_list_button_stretch_horizontal">false</bool> +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/colors.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/colors.xml new file mode 100644 index 0000000..0ca7b10 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/colors.xml @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- Colors used as the default value for overlayable resources. + These resources are not directly overlayable. --> + <color name="default_white">#FFFFFF</color> + <color name="default_gradient_white_12">#1FFFFFFF</color> + <color name="default_gradient_white_16">#29FFFFFF</color> + <color name="default_gradient_white_24">#3DFFFFFF</color> + <color name="default_gradient_white_40">#66FFFFFF</color> + <color name="default_gradient_white_46">#75FFFFFF</color> + <color name="default_gradient_white_56">#8FFFFFFF</color> + <color name="default_gradient_white_72">#B8FFFFFF</color> + <color name="default_gray_50">#F8F9FA</color> + <color name="default_gray_100">#F1F3F4</color> + <color name="default_gray_200">#E8EAED</color> + <color name="default_gray_300">#DADCE0</color> + <color name="default_gray_400">#BDC1C6</color> + <color name="default_gray_500">#9AA0A6</color> + <color name="default_gray_600">#80868B</color> + <color name="default_gray_700">#5F6368</color> + <color name="default_gray_800">#3C4043</color> + <color name="default_gray_846">#2E3134</color> + <color name="default_gray_868">#282A2D</color> + <color name="default_gray_878">#2A2A29</color> + <color name="default_gray_900">#202124</color> + <color name="default_gray_928">#17181B</color> + <color name="default_gray_958">#0E1013</color> + <color name="default_black">#000000</color> + <color name="default_gradient_black_0">#00000000</color> + <color name="default_gradient_black_25">#40000000</color> + <color name="default_gradient_black_64">#A3000000</color> + <color name="default_gradient_black_72">#B8000000</color> + <color name="default_gradient_black_85">#D9000000</color> + <color name="default_gradient_black_88">#E0000000</color> + <color name="default_gradient_black_100">#FF000000</color> + + <!-- Default colors. --> + <color name="default_text_color">@color/default_white</color> + + <!-- Standard colors --> + <color name="default_standard_red">#FFEE675C</color> + <color name="default_standard_red_dark">#FFC5221F</color> + <color name="default_standard_green">#FF61AC70</color> + <color name="default_standard_green_dark">#FF448B47</color> + <color name="default_standard_blue">#FF669DF6</color> + <color name="default_standard_blue_dark">#FF3674E0</color> + <color name="default_standard_yellow">#FFE9A240</color> + <color name="default_standard_yellow_dark">#FFD5792D</color> + + <!-- Default car app colors is customizable only with Car UI Library. --> + <color name="default_primary_color">@color/car_ui_text_color_primary</color> + <color name="default_primary_dark_color">@color/car_ui_text_color_primary</color> + <color name="default_secondary_color">@color/car_ui_text_color_secondary</color> + <color name="default_secondary_dark_color">@color/car_ui_text_color_secondary</color> + + <!-- LINT.IfChange --> + <color name="default_hun_text_color">@color/default_white</color> + <color name="default_hun_text_color2">#8FFFFFFF</color> + + <color name="default_card_text_color">@color/default_white</color> + <color name="default_background_color">@color/default_black</color> + <color name="default_card_background_color">@color/default_gray_846</color> + <color name="default_focus_blue">#4B9EFF</color> + <!-- LINT.ThenChange(../values-night/colors.xml) --> + + <color name="default_message_debug_text_color">#FF57F1B1</color> + + <color name="default_focus_no_content">#48FFFFFF</color> + <color name="default_controller_ripple_selector_color">#b27da9c7</color> + <color name="default_controller_ripple_color">#66ffffff</color> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/colors_overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/colors_overlayable.xml new file mode 100644 index 0000000..e9c2531 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/colors_overlayable.xml @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Color definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these colors directly from views. Colors must be referred + to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. + --> + + <!-- Standard colores --> + <color name="car_app_ui_standard_red">@color/default_standard_red</color> + <color name="car_app_ui_standard_red_dark">@color/default_standard_red_dark</color> + <color name="car_app_ui_standard_green">@color/default_standard_green</color> + <color name="car_app_ui_standard_green_dark">@color/default_standard_green_dark</color> + <color name="car_app_ui_standard_blue">@color/default_standard_blue</color> + <color name="car_app_ui_standard_blue_dark">@color/default_standard_blue_dark</color> + <color name="car_app_ui_standard_yellow">@color/default_standard_yellow</color> + <color name="car_app_ui_standard_yellow_dark">@color/default_standard_yellow_dark</color> + + <!-- Button --> + <color name="car_app_ui_action_button_default_background_color">@color/default_gray_846</color> + <color name="car_app_ui_action_button_primary_background_color">@color/default_standard_blue</color> + <color name="car_app_ui_action_button_text_color">@color/default_white</color> + <color name="car_app_ui_floating_button_default_background_color">@color/default_black</color> + <color name="car_app_ui_floating_button_default_text_color">@color/default_white</color> + + <!-- Read-only Text --> + <color name="car_app_ui_read_only_text_color">@color/default_black</color> + <color name="car_app_ui_read_only_text_background_color">@color/default_white</color> + + <!-- Edit Text --> + <color name="car_app_ui_edit_text_active_color">@color/car_app_ui_standard_blue</color> + <color name="car_app_ui_edit_text_enabled_color">@color/default_gradient_white_72</color> + <color name="car_app_ui_edit_text_error_color">@color/car_app_ui_standard_red</color> + <color name="car_app_ui_edit_text_disabled_color">@color/default_gradient_white_56</color> + + <!-- Hyperlink Text --> + <color name="car_app_ui_hyperlink_text_color">@color/default_white</color> + + <!-- Rows --> + <color name="car_app_ui_row_background_color">@color/car_ui_activity_background_color</color> + + <!-- Grids --> + <color name="car_app_ui_grid_item_background_color">@color/car_ui_activity_background_color</color> + +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/dimens_overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/dimens_overlayable.xml new file mode 100644 index 0000000..dd56d1e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/dimens_overlayable.xml @@ -0,0 +1,116 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Dimension definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these dimensions directly from views. Dimensions must be + referred to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <!-- Template element spacing --> + <dimen name="car_app_ui_image_to_text_spacing_vertical">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_text_to_control_spacing_vertical">@dimen/car_ui_padding_5</dimen> + <dimen name="car_app_ui_text_to_secondary_control_spacing_vertical">@dimen/car_ui_padding_7</dimen> + <dimen name="car_app_ui_control_to_text_spacing_vertical">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_control_to_control_spacing_horizontal">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_content_horizontal_margin">24dp</dimen> + <dimen name="car_app_ui_touch_target_size">@dimen/car_ui_touch_target_size</dimen> + + <!-- Template element corner radius --> + <dimen name="car_app_ui_corner_radius">8dp</dimen> + + <!-- Card spacing --> + <dimen name="car_app_ui_card_start_margin">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_card_top_margin">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_card_width">0dp</dimen> + + <!-- Template image sizing --> + <dimen name="car_app_ui_large_image_size">@dimen/car_ui_list_item_content_icon_width</dimen> + + <!-- Navigation card spacing --> + <dimen name="car_app_ui_nav_card_padding_vertical">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_nav_card_padding_horizontal">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_nav_card_image_to_text_spacing_horizontal">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_nav_card_large_text_size">32sp</dimen> + <dimen name="car_app_ui_nav_card_xlarge_text_size">44sp</dimen> + <dimen name="car_app_ui_nav_card_small_padding_vertical">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_nav_card_image_to_text_spacing_vertical">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_nav_card_width">0dp</dimen> + <dimen name="car_app_ui_nav_card_small_image_size">36dp</dimen> + <dimen name="car_app_ui_nav_card_large_image_size">64dp</dimen> + + <!-- Card header spacing/sizing --> + <dimen name="car_app_ui_card_header_image_size">44dp</dimen> + <dimen name="car_app_ui_card_header_text_padding_horizontal">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_card_header_text_padding_vertical">@dimen/car_ui_padding_2</dimen> + <dimen name="car_app_ui_card_header_no_button_text_margin_start">@dimen/car_ui_padding_4</dimen> + + <!-- Grid item spacing/sizing --> + <dimen name="car_app_ui_grid_item_vertical_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_grid_item_image_to_text_spacing_vertical">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_grid_item_text_to_text_spacing_vertical">@dimen/car_ui_padding_1</dimen> + + <!-- Button spacing/sizing --> + <dimen name="car_app_ui_button_height">56dp</dimen> + <dimen name="car_app_ui_button_image_size">36dp</dimen> + <dimen name="car_app_ui_icon_button_start_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_icon_button_end_spacing">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_icon_button_image_to_text_spacing">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_button_text_horizontal_spacing">@dimen/car_ui_padding_5</dimen> + <dimen name="car_app_ui_button_corner_radius">4dp</dimen> + <dimen name="car_app_ui_action_button_list_button_max_width">800dp</dimen> + <dimen name="car_app_ui_button_side_alignment_spacing">@dimen/car_ui_padding_4</dimen> + + <!-- Edit text spacing/sizing --> + <dimen name="car_app_ui_edit_text_top_padding">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_edit_text_bottom_padding">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_edit_text_start_padding">@dimen/car_ui_padding_2</dimen> + <dimen name="car_app_ui_edit_text_end_padding">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_edit_text_error_vertical_spacing">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_edit_text_error_horizontal_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_edit_text_border_width">2dp</dimen> + + <!-- Read-only Text. --> + <dimen name="car_app_ui_read_only_text_padding">@dimen/car_ui_padding_4</dimen> + + <!-- Compact row spacing/sizing. These rows are used inside cards. --> + <dimen name="car_app_ui_half_row_min_height">0dp</dimen> + <dimen name="car_app_ui_half_row_horizontal_padding">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_half_row_vertical_padding">@dimen/car_ui_padding_2</dimen> + <dimen name="car_app_ui_half_row_image_to_text_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_half_row_text_to_text_spacing">@dimen/car_ui_padding_0</dimen> + <dimen name="car_app_ui_half_row_image_size">44dp</dimen> + + <!-- Full row spacing/sizing. --> + <dimen name="car_app_ui_full_row_start_padding">@dimen/car_ui_list_item_text_start_margin</dimen> + <dimen name="car_app_ui_full_row_end_padding">0dp</dimen> + + <!-- Sign-in template spacing/sizing. --> + <dimen name="car_app_ui_sign_in_method_max_width">640dp</dimen> + +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/drawable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/drawable.xml new file mode 100644 index 0000000..f363b45 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/drawable.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- Drawables used as the default value for overlayable resources. + These resources are not directly overlayable. --> + <drawable name="default_error_icon">@drawable/car_ui_icon_error</drawable> + <drawable name="default_alert_icon">@drawable/car_ui_icon_error</drawable> + <drawable name="default_back_icon">@drawable/car_ui_icon_arrow_back</drawable> +</resources>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/drawable_overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/drawable_overlayable.xml new file mode 100644 index 0000000..042bbf4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/drawable_overlayable.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Drawable definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these drawables directly from views. Drawables must be referred + to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <drawable name="car_app_ui_action_button_background">@drawable/default_action_button_background</drawable> +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/integers.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/integers.xml new file mode 100644 index 0000000..0a589d5 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/integers.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- Gravity integer values (to be used as part of gravity overlayable attributes. --> +<!-- <integer name="gravity_bottom">80</integer>--> +<!-- <integer name="gravity_center">17</integer>--> +<!-- <integer name="gravity_center_horizontal">1</integer>--> +<!-- <integer name="gravity_center_vertical">16</integer>--> +<!-- <integer name="gravity_end">8388613</integer>--> +<!-- <integer name="gravity_left">3</integer>--> +<!-- <integer name="gravity_no_gravity">0</integer>--> +<!-- <integer name="gravity_right">5</integer>--> +<!-- <integer name="gravity_start">8388611</integer>--> +<!-- <integer name="gravity_top">48</integer>--> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/integers_overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/integers_overlayable.xml new file mode 100644 index 0000000..0123000 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/integers_overlayable.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Integer definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these integers directly from views. Integers must be + referred to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <integer name="car_app_ui_list_max_length">6</integer> + <integer name="car_app_ui_grid_max_length">6</integer> + <integer name="car_app_ui_action_button_primary_horizontal_order">0</integer> + <integer name="car_app_ui_action_button_list_gravity">0</integer> + <integer name="car_app_ui_action_button_list_button_content_alignment">0</integer> + <integer name="car_app_ui_content_layout_gravity">@integer/gravity_center</integer> + <integer name="car_app_ui_content_gravity">@integer/gravity_center</integer> +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/overlayable.xml new file mode 100644 index 0000000..289f8bc --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/overlayable.xml @@ -0,0 +1,306 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- List of the resource that can be customized by the OEMs by using + Runtime Resource Overlays. + + !!! IMPORTANT !!! + + Comments on this file are used to produce automatically generated + documentation available at https://docs.partner.android.com/gas/integrate/template_host. + + Once per AAOS Host release, the following tool should be used to re-generate the publicly + documented resource list. This list constitutes an API with the OEMs. DO NOT remove or + rename an existing resource without a corresponding deprecation cycle. + + third_party/java_src/android_libs/car/aaos_host/main/com/android/car/libraries/templates/host/overlayable/tools/generateDoc.py + --> + <overlayable name="OverlayableResources"> + <policy type="system|product|vendor|signature"> + + <!-- Indicates whether OEMs have done any UI customizations. This value should be set to true + by the OEMs who wish to provide UI customization. --> + <item type="bool" name="car_app_ui_customized" /> + <!-- Indicates whether OEMs choose to ignore app provided colors on + buttons on select templates. This value should be set to true by the + OEMs who wish to ignore app provided colors on buttons on select + templates. --> + <item type="bool" name="car_app_ui_is_action_color_overridden" /> + <!-- Indicates whether buttons in the action button list (e.g. used in PaneTemplate) + stretch to fill the horizontal space. --> + <item type="bool" name="car_app_ui_action_button_list_button_stretch_horizontal" /> + + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_red" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_red_dark" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_green" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_green_dark" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_blue" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_blue_dark" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_yellow" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_yellow_dark" /> + <!-- Default background color used on 'Action' buttons when one is not provided by the + application. --> + <item type="color" name="car_app_ui_action_button_default_background_color" /> + <!-- Background color used on 'Action' buttons marked as 'Primary', when one is not provided + by the application. --> + <item type="color" name="car_app_ui_action_button_primary_background_color" /> + <!-- Text color used on 'Action' buttons when one is not provided by the application. --> + <item type="color" name="car_app_ui_action_button_text_color" /> + <!-- Background color used on FABs (floating action buttons) when one is not provided by the + application. --> + <item type="color" name="car_app_ui_floating_button_default_background_color" /> + <!-- Text color used on FABs (floating action buttons) when one is not provided by the + application. --> + <item type="color" name="car_app_ui_floating_button_default_text_color" /> + <!-- Text color used on read-only text boxes (such as the PIN code in Sign-In template). --> + <item type="color" name="car_app_ui_read_only_text_color" /> + <!-- Background color used on read-only text boxes (such as the PIN code in Sign-In + template). --> + <item type="color" name="car_app_ui_read_only_text_background_color" /> + <!-- Edit box 'active' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_active_color" /> + <!-- Edit box 'enabled' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_enabled_color" /> + <!-- Edit box 'error' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_error_color" /> + <!-- Edit box 'disabled' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_disabled_color"/> + <!-- Text color used in 'clickable spans' (such as the ones allowed in Sign-In template). --> + <item type="color" name="car_app_ui_hyperlink_text_color" /> + <!-- The background color of a row container view to check color contrast against its contents. + This color is used only for color contrast checks, and not for actual background coloring. + Set an appropriate value if the row background color is customized. --> + <item type="color" name="car_app_ui_row_background_color" /> + <!-- The background color of a grid item view to check color contrast against its contents. + This color is used only for color contrast checks, and not for actual background coloring. + Set an appropriate value if the grid background color is customized. --> + <item type="color" name="car_app_ui_grid_item_background_color" /> + + <!-- Vertical space between an image and a text --> + <item type="dimen" name="car_app_ui_image_to_text_spacing_vertical" /> + <!-- Vertical space between a text and a control (such as an edit box to instruction text). --> + <item type="dimen" name="car_app_ui_text_to_control_spacing_vertical" /> + <!-- Vertical space between a text and a secondary control (such as an action button list view to additional text). --> + <item type="dimen" name="car_app_ui_text_to_secondary_control_spacing_vertical" /> + <!-- Vertical space between a control (such as an edit box) and a text. --> + <item type="dimen" name="car_app_ui_control_to_text_spacing_vertical" /> + <!-- Horizontal space between two controls (such two buttons in an Action Strip). --> + <item type="dimen" name="car_app_ui_control_to_control_spacing_horizontal" /> + <!-- Horizontal space around content areas such as full screen lists and grids. --> + <item type="dimen" name="car_app_ui_content_horizontal_margin" /> + <!-- Touch target size, used to define the size of header buttons, for example. --> + <item type="dimen" name="car_app_ui_touch_target_size" /> + <!-- Corner radius used across the UI except for the buttons. --> + <item type="dimen" name="car_app_ui_corner_radius" /> + <!-- Card width (expect for navigation card). If not set, the card width will be defined by + the host in proportion to the screen size. This value must be within the template host + defined range. --> + <item type="dimen" name="car_app_ui_card_width" /> + <!-- Width and height of large images (such as list and grid items, and message and + sign-in images. --> + <item type="dimen" name="car_app_ui_large_image_size" /> + <!-- Vertical space between the nav card content and its container. --> + <item type="dimen" name="car_app_ui_nav_card_padding_vertical" /> + <!-- Horizontal space between the navigation card content and its container. --> + <item type="dimen" name="car_app_ui_nav_card_padding_horizontal" /> + <!-- Horizontal space between an image and a text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_image_to_text_spacing_horizontal" /> + <!-- Vertical space between an image and a text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_image_to_text_spacing_vertical" /> + <!-- Size of xlarge text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_xlarge_text_size" /> + <!-- Size of large text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_large_text_size" /> + <!-- Vertical space applied in navigation card when lane images are present, for example. --> + <item type="dimen" name="car_app_ui_nav_card_small_padding_vertical" /> + <!-- Navigation card width. If not set, the card width will be defined by the host in + proportion to the screen size. This value must be within the host defined maximum + range. --> + <item type="dimen" name="car_app_ui_nav_card_width" /> + <!-- Size of small images inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_small_image_size" /> + <!-- Size of large images inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_large_image_size" /> + <!-- Size of an image inside a card header. --> + <item type="dimen" name="car_app_ui_card_header_image_size" /> + <!-- Horizontal space between a text (e.g. a title) and the border of a card. --> + <item type="dimen" name="car_app_ui_card_header_text_padding_horizontal" /> + <!-- Vertical space between a text (e.g. a title) and the border of a card. --> + <item type="dimen" name="car_app_ui_card_header_text_padding_vertical" /> + <!-- Horizontal space between a text (e.g. a title) and the border of a card when no header + button is included. --> + <item type="dimen" name="car_app_ui_card_header_no_button_text_margin_start" /> + <!-- Vertical space between grid items --> + <item type="dimen" name="car_app_ui_grid_item_vertical_spacing" /> + <!-- Vertical space between an image and a text inside a grid item. --> + <item type="dimen" name="car_app_ui_grid_item_image_to_text_spacing_vertical" /> + <!-- Vertical space between an two texts inside a grid item. --> + <item type="dimen" name="car_app_ui_grid_item_text_to_text_spacing_vertical" /> + <!-- Buttons height. --> + <item type="dimen" name="car_app_ui_button_height" /> + <!-- Image size inside a button. --> + <item type="dimen" name="car_app_ui_button_image_size" /> + <!-- Horizontal space between the start and end sides of a FAB or button and the action + text. The spacing is applied only when the button only has the text. + If `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right), this value will be ignored. --> + <item type="dimen" name="car_app_ui_button_text_horizontal_spacing" /> + <!-- Horizontal space between the icon and the text in a FAB or button. --> + <item type="dimen" name="car_app_ui_icon_button_image_to_text_spacing" /> + <!-- Horizontal space between the start side of a FAB or button and the action icon. The + spacing is applied only when the button has both icon and text. + If `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right), this value will be ignored. --> + <item type="dimen" name="car_app_ui_icon_button_start_spacing" /> + <!-- Horizontal space between the end side of a FAB or button and the action icon. The + spacing is applied only when the button has both icon and text. + If `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right), this value will be ignored. --> + <item type="dimen" name="car_app_ui_icon_button_end_spacing" /> + <!-- Corner radius applied to buttons. --> + <item type="dimen" name="car_app_ui_button_corner_radius" /> + <!-- The maximum width of a button in the action button list, e.g. used in PaneTemplate. + This value will be used only when `car_app_ui_action_button_list_button_stretch_horizontal` is set to `true`. --> + <item type="dimen" name="car_app_ui_action_button_list_button_max_width" /> + <!-- The horizontal spacing around the content in a button in the action button list, e.g. used in PaneTemplate. + This value will be used only when `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right). + When this value is used, `car_app_ui_icon_button_start_spacing`, `car_app_ui_icon_button_end_spacing`, and `car_app_ui_button_text_horizontal_spacing` will be ignored. --> + <item type="dimen" name="car_app_ui_button_side_alignment_spacing" /> + <!-- Edit box top vertical space --> + <item type="dimen" name="car_app_ui_edit_text_top_padding" /> + <!-- Edit box bottom vertical space --> + <item type="dimen" name="car_app_ui_edit_text_bottom_padding" /> + <!-- Edit box start side horizontal space --> + <item type="dimen" name="car_app_ui_edit_text_start_padding" /> + <!-- Edit box end side horizontal space --> + <item type="dimen" name="car_app_ui_edit_text_end_padding" /> + <!-- Vertical space between the edit box and the associated error message. --> + <item type="dimen" name="car_app_ui_edit_text_error_vertical_spacing" /> + <!-- Horizontal space between the edit box error message and its container. --> + <item type="dimen" name="car_app_ui_edit_text_error_horizontal_spacing" /> + <!-- Horizontal space around the text in read-only boxes (such as the PIN code in Sign-In + template). --> + <item type="dimen" name="car_app_ui_read_only_text_padding" /> + <!-- Width of a border around or under the edit box, showing the different states of the box. --> + <item type="dimen" name="car_app_ui_edit_text_border_width"/> + <!-- Start padding to list items in full lists (such as ListTemplate) --> + <item type="dimen" name="car_app_ui_full_row_start_padding" /> + <!-- End padding to list items in full lists (such as ListTemplate) --> + <item type="dimen" name="car_app_ui_full_row_end_padding" /> + <!-- Minimum height of a list item in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_min_height" /> + <!-- Horizontal space around list items in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_horizontal_padding" /> + <!-- Vertical space around list items in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_vertical_padding" /> + <!-- Horizontal space between image and text in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_image_to_text_spacing" /> + <!-- Horizontal space between two texts in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_text_to_text_spacing" /> + <!-- Image sizes in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_image_size" /> + <!-- Sign-in template authentication methods max width. --> + <item type="dimen" name="car_app_ui_sign_in_method_max_width" /> + + <!-- Drawable used for action buttons background. The default value will render these + actions as solid rectangles with rounded corners (corner radius defined by + 'car_app_ui_button_corner_radius'). Background color will be + 'car_app_ui_action_button_default_background_color' or + 'car_app_ui_action_button_primary_background_color', depending on whether the button + is primary or not. + Buttons have the following custom selectors: + <ul> + <li>type_primary: Indicates the button is a primary one. + <li>type_custom: Indicate the colors of this button depend on app provided colors. + </ul> + When a button is marked as 'custom', the app provided background color is applied as a + tint over this drawable. --> + <item type="drawable" name="car_app_ui_action_button_background" /> + + <!-- Maximum number of items to show in a list. This can't be lower than 6 --> + <item type="integer" name="car_app_ui_list_max_length" /> + <!-- Maximum number of items to show in a grid. This can't be lower than 6 --> + <item type="integer" name="car_app_ui_grid_max_length" /> + <!-- Indicates the horizontal order that OEMs pick for the primary action + on selected templates. + <ul> + <li>0 means no re-order + <li>1 indicates primary action should be on the left + <li>2 indicates primary action should be on the right + </ul> + On horizontal buttons, + --> + <item type="integer" name="car_app_ui_action_button_primary_horizontal_order" /> + + <!-- The gravity of action button list (e.g. used in MessageTemplate and PaneTemplate). + The possible values are: + <ul> + <li>0: center (default) + <li>1: bottom + </ul> --> + <item type="integer" name="car_app_ui_action_button_list_gravity" /> + <!-- The alignment of contents in buttons in the action button list (e.g. used in PaneTemplate). + The possible values are: + <ul> + <li>0: center (default) + <li>1: left + <li>2: right + </ul> --> + <item type="integer" name="car_app_ui_action_button_list_button_content_alignment" /> + <!-- Layout gravity for content areas (e.g content vertical alignment in Sign In Template + content).--> + <item type="integer" name="car_app_ui_content_layout_gravity"/> + <!-- Content gravity for content areas (e.g. content horizontal alignment in Sign In + Template content). --> + <item type="integer" name="car_app_ui_content_gravity"/> + + <!-- General paragraph text appareance --> + <item type="style" name="TextAppearance.CarAppUi.TextBlock" /> + <!-- Sign-in header text appareance --> + <item type="style" name="TextAppearance.CarAppUi.SignInHeader" /> + <!-- Sign-in legal notice text appareance --> + <item type="style" name="TextAppearance.CarAppUi.SignInLegal" /> + <!-- Card header appareance (e.g. Place List Template) --> + <item type="style" name="TextAppearance.CarAppUi.CardHeader" /> + <!-- Grid item title text appareance --> + <item type="style" name="TextAppearance.CarAppUi.GridItemTitle" /> + <!-- Grid item description text appareance --> + <item type="style" name="TextAppearance.CarAppUi.GridItemText" /> + <!-- Buttons text appareance --> + <item type="style" name="TextAppearance.CarAppUi.ButtonText" /> + <!-- Read-only text appareance --> + <item type="style" name="TextAppearance.CarAppUi.ReadOnlyText"/> + <!-- Style applied to input views (e.g. Sign-In username box) --> + <item type="style" name="Widget.CarAppUi.InputView" /> + <!-- Style applied to edit boxes --> + <item type="style" name="Widget.CarAppUi.EditText" /> + <!-- Style applied to row sections headers (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowSectionHeader" /> + <!-- Style applied to row title (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowTitle" /> + <!-- Style applied to row secondary text (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowSecondary" /> + <!-- Style applied to list empty text (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowListEmpty" /> + </policy> + </overlayable> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/styles_overlayable.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/styles_overlayable.xml new file mode 100644 index 0000000..2162364 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/overlayable/res/values/styles_overlayable.xml @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Style definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these styles directly from views. Styles must be referred + to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. --> + + <!-- Template textAppearance --> + <style name="TextAppearance.CarAppUi.TextBlock" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.SignInHeader" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.SignInLegal" parent="TextAppearance.CarUi.Body3" /> + <style name="TextAppearance.CarAppUi.CardHeader" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.GridItemTitle" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.GridItemText" parent="TextAppearance.CarUi.Body3"> + <item name="android:textColor">@color/car_ui_text_color_secondary</item> + </style> + <style name="TextAppearance.CarAppUi.ButtonText" parent="TextAppearance.CarUi.Body3" /> + <style name="TextAppearance.CarAppUi.ReadOnlyText" parent="TextAppearance.CarUi.Body3"> + <item name="android:textColor">@color/car_app_ui_read_only_text_color</item> + </style> + + <!-- Input view styling --> + <style name="Widget.CarAppUi.InputView" parent=""> + <item name="android:gravity">start</item> + </style> + + <!-- Edit text styling --> + <style name="Widget.CarAppUi.EditText" parent="android:Widget.DeviceDefault.EditText"> + <item name="android:textColor">@color/default_edit_text_color_selector</item> + <item name="android:textColorHint">@color/default_edit_text_hint_color_selector</item> + <item name="android:paddingTop">@dimen/car_app_ui_edit_text_top_padding</item> + <item name="android:paddingBottom">@dimen/car_app_ui_edit_text_bottom_padding</item> + <item name="android:paddingStart">@dimen/car_app_ui_edit_text_start_padding</item> + <item name="android:paddingEnd">@dimen/car_app_ui_edit_text_end_padding</item> + <item name="android:background">@drawable/default_edit_text_background</item> + <item name="android:foreground">@drawable/default_edit_text_foreground</item> + </style> + + <!-- The style of the list section header. --> + <style name="Widget.CarAppUi.RowSectionHeader" parent="TextAppearance.CarUi.ListItem.Header"> + <item name="android:textAlignment">textStart</item> + <item name="android:layout_marginStart">@dimen/car_ui_padding_4</item> + <item name="android:layout_marginVertical">@dimen/car_ui_padding_2</item> + </style> + + <!-- The style of the title text in a list row. --> + <style name="Widget.CarAppUi.RowTitle" parent=""> + <item name="android:textAppearance">@style/TextAppearance.CarUi.ListItem</item> + <item name="android:textAlignment">viewStart</item> + <item name="android:singleLine">@bool/car_ui_list_item_single_line_title</item> + </style> + + <!-- The style of the secondary text in a list row. --> + <style name="Widget.CarAppUi.RowSecondary" parent=""> + <item name="android:textAppearance">@style/TextAppearance.CarUi.ListItem.Body</item> + <item name="android:textAlignment">viewStart</item> + </style> + + <!-- The style of text that indicates a list is empty --> + <style name="Widget.CarAppUi.RowListEmpty" parent="Widget.CarAppUi.RowSecondary"> + <item name="android:maxLines">2</item> + <item name="android:gravity">center</item> + </style> + +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/LegacySurfaceController.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/LegacySurfaceController.kt new file mode 100644 index 0000000..bb10468 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/LegacySurfaceController.kt @@ -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.templates.host.renderer + +import android.app.Presentation +import android.content.Context +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.Build +import android.util.Log +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Surface +import android.view.SurfaceControlViewHost +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.car.app.activity.renderer.surface.LegacySurfacePackage +import androidx.car.app.activity.renderer.surface.SurfaceControlCallback +import androidx.car.app.activity.renderer.surface.SurfaceWrapper +import androidx.car.app.serialization.Bundleable +import androidx.car.app.utils.ThreadUtils +import com.android.car.libraries.apphost.common.TemplateContext +import com.android.car.libraries.apphost.logging.LogTags +import com.android.car.libraries.apphost.logging.StatusReporter +import java.io.PrintWriter +import java.util.function.Consumer + +/** + * A presenter similar to [SurfaceControlViewHost] that conforms to [SurfaceController]. + * + * <p>This presenter should only be used if API version is lower than [Build.VERSION_CODES.R]. + * Otherwise, [SurfaceControlViewHostController] should be used. + */ +class LegacySurfaceController( + private val context: Context, + private val templateContext: TemplateContext, + private val errorHandler: Consumer<Throwable> +) : SurfaceController { + + private var presentation: Presentation? = null + private var virtualDisplay: VirtualDisplay? = null + set(value) { + field?.release()?.also { Log.d(LogTags.APP_HOST, "Released old Display") } + field = value + } + private var width: Int = 0 + private var height: Int = 0 + private var densityDpi: Int = 0 + private var contentView: View? = null + + /** An interface for listening to key events. */ + // TODO(b/192397819): Remove once SurfaceControlCallback supports the interface. + interface OnKeyListener { + /** Notifies the key event. */ + fun onKeyEvent(event: KeyEvent) + } + + private val surfaceControl = + object : SurfaceControlCallback, OnKeyListener { + override fun setSurfaceWrapper(surfaceWrapper: SurfaceWrapper) { + ThreadUtils.runOnMain { + // Since {@link SurfaceHolder.Callback} gives a guarantee that + // {@link SurfaceHolder.Callback#surfaceChanged} "is always called at least once, after" + // {@link SurfaceHolder.Callback#surfaceCreated}, we should only call + // {@link #updatePresentation} if the library is not adjusting insets. This will prevent + // two virtual displays from being created with back-to-back calls of + // {@link #setSurfaceWrapper} and {@link #relayout} when library is adjusting insets. + if (!libraryAdjustsInsets(templateContext.carHostConfig.appInfo?.libraryDisplayVersion)) { + Log.d( + LogTags.APP_HOST, + "SetSurfaceWrapper: " + "(${surfaceWrapper.width} x ${surfaceWrapper.height})" + ) + updatePresentation(surfaceWrapper) + } + } + } + + override fun onError(msg: String, e: Throwable) { + Log.e(LogTags.APP_HOST, msg, e) + errorHandler.accept(e) + } + + override fun onWindowFocusChanged(hasFocus: Boolean, isInTouchMode: Boolean) { + ThreadUtils.runOnMain { + if (contentView != null) { + presentation?.window?.setLocalFocus(hasFocus, isInTouchMode) + } + } + } + + override fun onTouchEvent(event: MotionEvent) { + ThreadUtils.runOnMain { presentation?.window?.injectInputEvent(event) } + } + + override fun onKeyEvent(event: KeyEvent) { + ThreadUtils.runOnMain { presentation?.window?.superDispatchKeyEvent(event) } + } + } + + private val surfacePackage = Bundleable.create(LegacySurfacePackage(surfaceControl)) + + override fun obtainSurfacePackage(): Bundleable = surfacePackage + + override fun releaseSurfacePackage(value: Bundleable) { + // Nothing to do here. LegacySurfacePackage doesn't need to be released. + } + + override fun releaseSurface() { + virtualDisplay?.surface = null + } + + // TODO(b/208313104): Remove once majority of 3p applications migrated to 1.2.0-alpha-02. + private fun libraryAdjustsInsets(libraryDisplayVersion: String?): Boolean { + if (libraryDisplayVersion == null || + libraryDisplayVersion.startsWith("1.1") || + libraryDisplayVersion == "1.2.0-alpha01" + ) { + return false + } + return true + } + + override fun relayout(surfaceWrapper: SurfaceWrapper) { + Log.i(LogTags.APP_HOST, "Relayout: " + "(${surfaceWrapper.width} x ${surfaceWrapper.height})") + + if (libraryAdjustsInsets(templateContext.carHostConfig.appInfo?.libraryDisplayVersion)) { + Log.i(LogTags.APP_HOST, "Library does adjust insets.") + // A size change in the surface view requires a change in the dimensions of the virtual + // display created on top of such surface. This can only be achieved by recreating the display + // and adjusting the presentation on top of it. For this to be efficient, insets changes + // should be managed on the host side (see InsetsListener), in order to avoid unnecessary + // display recreations. + updatePresentation(surfaceWrapper) + } else { + Log.i(LogTags.APP_HOST, "Library does not adjust insets.") + // When library does not adjust the insets, host gets relayout calls even when the keyboard is + // displayed. In this case we should not recreate the presentation since that will release the + // first responder and dismissed the keyboard. Instead we need to adjust the size of the + // containerView. + contentView?.layoutParams = + FrameLayout.LayoutParams(surfaceWrapper.width, surfaceWrapper.height) + } + } + + override fun setView(view: View, width: Int, height: Int) { + contentView = view + } + + override fun reportStatus(pw: PrintWriter, piiHandling: StatusReporter.Pii) { + pw.printf( + "- virtual display id: %s, width: %d, height: %d, density: %d dpi\n", + virtualDisplay?.display?.displayId ?: "-", + contentView?.layoutParams?.width ?: 0, + contentView?.layoutParams?.height ?: 0, + densityDpi + ) + } + + private fun updatePresentation(surfaceWrapper: SurfaceWrapper) { + if (!reuseVirtualDisplay(surfaceWrapper)) { + setNewDisplayAndPresentation(surfaceWrapper) + } + + // Attach contentView to Presentation if it's not already there + contentView?.takeIf { !it.isAttachedTo(presentation) }?.let { contentView -> + Log.i(LogTags.APP_HOST, "Attaching contentView to Presentation") + (contentView.parent as ViewGroup?)?.removeView(contentView) + presentation?.setContentView(contentView) + contentView.layoutParams = FrameLayout.LayoutParams(width, height) + contentView.invalidate() + } + } + + /** + * Attaches the new [Surface] to an existing [VirtualDisplay], if possible. + * + * @return [false] if there's no existing [VirtualDisplay], or its dimensions don't match. [true] + * if reuse was possible. + */ + private fun reuseVirtualDisplay(surfaceWrapper: SurfaceWrapper): Boolean { + if (virtualDisplay != null && + width == surfaceWrapper.width && + height == surfaceWrapper.height && + densityDpi == surfaceWrapper.densityDpi + ) { + Log.i(LogTags.APP_HOST, "Reusing existing VirtualDisplay with new Surface ($width x $height)") + virtualDisplay?.surface = surfaceWrapper.surface + return true + } + return false + } + + /** + * Creates, stores and shows a new [VirtualDisplay] and [Presentation] for the given + * [SurfaceWrapper]. + */ + private fun setNewDisplayAndPresentation(surfaceWrapper: SurfaceWrapper) { + Log.i( + LogTags.APP_HOST, + "Creating new VirtualDisplay and Presentation " + + "(${surfaceWrapper.width} x ${surfaceWrapper.height})" + ) + val displayManager = context.getSystemService(DisplayManager::class.java) + virtualDisplay = + displayManager.createVirtualDisplay( + VIRTUAL_DISPLAY_NAME, + surfaceWrapper.width, + surfaceWrapper.height, + surfaceWrapper.densityDpi, + surfaceWrapper.surface, + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY + ) + width = surfaceWrapper.width + height = surfaceWrapper.height + densityDpi = surfaceWrapper.densityDpi + presentation = Presentation(PresentationContext(context), virtualDisplay?.display) + presentation?.show() + } + + protected fun finalize() { + virtualDisplay?.release() + virtualDisplay = null + width = 0 + height = 0 + densityDpi = 0 + } + + companion object { + const val VIRTUAL_DISPLAY_NAME = "ScreenRendererVirtualDisplay" + } +} + +private fun View.isAttachedTo(presentation: Presentation?): Boolean = + parent != null && parent == presentation?.findViewById(android.R.id.content) diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/PresentationContext.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/PresentationContext.kt new file mode 100644 index 0000000..d65e237 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/PresentationContext.kt @@ -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.templates.host.renderer + +import android.content.Context +import android.content.ContextWrapper +import android.view.Display +import android.view.inputmethod.InputMethodManager + +/** + * The context used for the [Presentation] of [LegacySurfaceController]. + * + * This context injects its main [InputMethodManager] to its display contexts to avoid display + * mismatch which results in polluted logs. + */ +internal class PresentationContext(base: Context) : ContextWrapper(base) { + + private class PresentationDisplayContext( + base: Context, + private val inputMethodManager: InputMethodManager + ) : ContextWrapper(base) { + + override fun getSystemService(name: String): Any { + return if (INPUT_METHOD_SERVICE == name) inputMethodManager else super.getSystemService(name) + } + } + + override fun createDisplayContext(display: Display): Context { + val inputMethodManager = + baseContext.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + val context = super.createDisplayContext(display) + return PresentationDisplayContext(context, inputMethodManager) + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/ScreenRenderer.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/ScreenRenderer.kt new file mode 100644 index 0000000..8f7b4e0 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/ScreenRenderer.kt @@ -0,0 +1,442 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.renderer + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.os.RemoteException +import android.os.SystemClock +import android.util.Log +import android.view.View +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.VisibleForTesting +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.activity.renderer.ICarAppActivity +import androidx.car.app.activity.renderer.surface.ISurfaceListener +import androidx.car.app.activity.renderer.surface.SurfaceWrapper +import androidx.car.app.model.TemplateWrapper +import androidx.car.app.serialization.Bundleable +import androidx.car.app.utils.ThreadUtils +import com.android.car.libraries.apphost.CarHost +import com.android.car.libraries.apphost.common.BackPressedHandler +import com.android.car.libraries.apphost.common.CarAppManager +import com.android.car.libraries.apphost.common.EventManager +import com.android.car.libraries.apphost.common.HostResourceIds +import com.android.car.libraries.apphost.common.IntentUtils +import com.android.car.libraries.apphost.common.LocationMediator +import com.android.car.libraries.apphost.common.StatusBarManager +import com.android.car.libraries.apphost.common.SurfaceCallbackHandler +import com.android.car.libraries.apphost.common.TemplateContext +import com.android.car.libraries.apphost.internal.LocationMediatorImpl +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.TelemetryHandler +import com.android.car.libraries.apphost.nav.NavigationHost +import com.android.car.libraries.apphost.template.AppHost +import com.android.car.libraries.apphost.template.ConstraintHost +import com.android.car.libraries.apphost.template.UIController +import com.android.car.libraries.apphost.view.SurfaceProvider +import com.android.car.libraries.templates.host.di.FeaturesConfig +import com.android.car.libraries.templates.host.di.HostApiLevelConfig +import com.android.car.libraries.templates.host.di.ThemeManager +import com.android.car.libraries.templates.host.di.UxreConfig +import com.android.car.libraries.templates.host.internal.CarActivityDispatcher +import com.android.car.libraries.templates.host.internal.CarAppServiceInfo +import com.android.car.libraries.templates.host.internal.CarHostRepository +import com.android.car.libraries.templates.host.internal.DebugOverlayHandlerImpl +import com.android.car.libraries.templates.host.internal.InputConfigImpl +import com.android.car.libraries.templates.host.internal.InputManagerImpl +import com.android.car.libraries.templates.host.internal.InsetsListener +import com.android.car.libraries.templates.host.internal.NavigationStateCallbackImpl +import com.android.car.libraries.templates.host.internal.RendererCallback +import com.android.car.libraries.templates.host.internal.StartCarAppUtil +import com.android.car.libraries.templates.host.internal.TemplateContextImpl +import com.android.car.libraries.templates.host.view.TemplateView +import java.io.PrintWriter + +/** + * A class used to handle rendering of a single car app screen. + * + * <p>Once the activity is ready the [onCreateActivity] should be called to start the rendering. + * + * @property appName Points to the car app service which provides the data for the screen. + * @param display The display on which the content should be displayed. + */ +class ScreenRenderer( + private val context: Context, + private val appName: ComponentName, + displayId: Int, + private val callback: CarActivityDispatcher.Callback, + hostResourceIds: HostResourceIds, + uxreConfig: UxreConfig, + hostApiLevelConfig: HostApiLevelConfig, + themeManager: ThemeManager, + telemetryHandler: TelemetryHandler, + featuresConfig: FeaturesConfig, + isDebugOverlayActive: Boolean +) : BackPressedHandler, SurfaceCallbackHandler, StatusBarManager { + private var surfaceController: SurfaceController? = null + private lateinit var carActivity: CarActivityDispatcher + private val carAppManager = CarAppManagerImpl() + private val carAppServiceInfo = CarAppServiceInfo(context, appName) + private val isNavigationApp = carAppServiceInfo.isNavigationService + @VisibleForTesting var lastTemplate: TemplateWrapper? = null + private val mainHandler = Handler(Looper.getMainLooper(), HandlerCallback()) + private val inputManagerListener = + object : InputManagerImpl.InputManagerListener { + override fun onStartInput() { + carActivity.dispatch(ICarAppActivity::onStartInput) + } + + override fun onStopInput() { + carActivity.dispatch(ICarAppActivity::onStopInput) + } + + override fun onUpdateSelection( + oldSelStart: Int, + oldSelEnd: Int, + newSelStart: Int, + newSelEnd: Int + ) { + carActivity.dispatchNoFail { + it.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd) + } + } + } + + private val inputManager = InputManagerImpl(inputManagerListener) + + private val inputConfig = InputConfigImpl() + + val templateContext: TemplateContext = + TemplateContextImpl.create( + context, + appName, + displayId, + this, + this, + this, + DebugOverlayHandlerImpl(isDebugOverlayActive), + inputManager, + inputConfig, + carAppManager, + isNavigationApp, + hostResourceIds, + uxreConfig, + hostApiLevelConfig, + themeManager, + telemetryHandler, + featuresConfig + ) + + private var templateView = TemplateView.create(templateContext) + + @VisibleForTesting + val uiController = + object : UIController { + override fun getSurfaceProvider(appName: ComponentName?): SurfaceProvider { + return templateView.surfaceProvider + } + + override fun setTemplate(appName: ComponentName?, template: TemplateWrapper?) { + mainHandler.removeMessages(MSG_SET_TEMPLATE) + val msg = mainHandler.obtainMessage(MSG_SET_TEMPLATE) + msg.obj = template + + mainHandler.sendMessage(msg) + } + } + + init { + val locationMediator = + LocationMediatorImpl.create(templateContext.eventManager) { enable: Boolean -> + trySetEnableAppLocationUpdates(enable) + } + templateContext.registerAppHostService(LocationMediator::class.java, locationMediator) + } + + override fun onBackPressed() { + val carHost = CarHostRepository.get(appName) + val appHost = carHost?.getHostOrThrow(CarContext.APP_SERVICE) as? AppHost + appHost?.onBackPressed() + } + + override fun onScroll(distanceX: Float, distanceY: Float) { + val carHost = CarHostRepository.get(appName) + val appHost = carHost?.getHostOrThrow(CarContext.APP_SERVICE) as? AppHost + appHost?.onSurfaceScroll(distanceX, distanceY) + } + + override fun onFling(velocityX: Float, velocityY: Float) { + val carHost = CarHostRepository.get(appName) + val appHost = carHost?.getHostOrThrow(CarContext.APP_SERVICE) as? AppHost + appHost?.onSurfaceFling(velocityX, velocityY) + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + val carHost = CarHostRepository.get(appName) + val appHost = carHost?.getHostOrThrow(CarContext.APP_SERVICE) as? AppHost + appHost?.onSurfaceScale(focusX, focusY, scaleFactor) + } + + override fun setStatusBarState( + statusBarState: StatusBarManager.StatusBarState?, + rootView: View? + ) { + // TODO: Not yet implemented + Log.v( + LogTags.APP_HOST, + "StatusBar state updated to $statusBarState. " + "RootView is $rootView." + ) + } + + /** Requests to enable or disable location updates from the app. */ + private fun trySetEnableAppLocationUpdates(enabled: Boolean) { + val carHost = CarHostRepository.get(appName) + val appHost = carHost?.getHostOrThrow(CarContext.APP_SERVICE) as? AppHost + appHost?.trySetEnableLocationUpdates(enabled) + } + + private fun createBinderIntent(intent: Intent) = + Intent().apply { + action = CarAppService.SERVICE_INTERFACE + component = appName + IntentUtils.embedOriginalIntent(this, intent) + } + + fun onCreateActivity(carActivity: ICarAppActivity) { + this.carActivity = CarActivityDispatcher(appName, carActivity, callback) + val carHost = CarHostRepository.computeIfAbsent(appName) { CarHost.create(templateContext) } + carHost.registerHostService(CarContext.APP_SERVICE) { appBinding -> + AppHost.create(uiController, appBinding, templateContext) + } + carHost.registerHostService(CarContext.CONSTRAINT_SERVICE) { + ConstraintHost.create(templateContext) + } + + // Register the navigation host service only if the app is a navigation app. An + // exception will be thrown if non-nav apps try to request access to the + // navigation host service. + if (templateContext.carAppPackageInfo.isNavigationApp) { + L.d(LogTags.NAVIGATION, "Registering navigation service") + carHost.registerHostService(CarContext.NAVIGATION_SERVICE) { appBinding: Any? -> + NavigationHost.create( + appBinding, + templateContext, + NavigationStateCallbackImpl.create(templateContext) + ) + } + } + + // Before returning the CarHost instance, check that the AppHost service still has a + // reference to the UiController instance of this activity, and if not, update it. + // This could happen if the activity is destroyed and re-created after, while the + // CarAppService binding remains alive through those changes. + val appHost: AppHost = carHost.getHostOrThrow(CarContext.APP_SERVICE) as AppHost + if (uiController != appHost.getUIController()) { + L.d( + LogTags.APP_HOST, + "Activity has been re-created, updating UI controller and " + + "template context in the host services" + ) + appHost.setUIController(uiController) + carHost.setTemplateContext(templateContext) + } + + templateView.setParentLifecycle(carHost.lifecycle) + templateView.setTemplateContext(templateContext) + + val surfaceListener = surfaceListener(carActivity, carHost) + carActivity.setSurfaceListener(surfaceListener) + + templateContext.eventManager.subscribeEvent(this, EventManager.EventType.CONSTRAINTS) { + reloadTemplate() + } + } + + fun onNewIntent(intent: Intent) { + val binderIntent = createBinderIntent(intent) + CarHostRepository.get(appName)?.bindToApp(binderIntent) + } + + /** Updates the context with given configuration. */ + fun onConfigurationChanged(config: Configuration) { + templateContext.updateConfiguration(config) + } + + /** + * Called when the activity has disconnected from the renderer service. This instance shouldn't be + * used again after this point. + */ + fun onDestroy() { + L.d( + LogTags.APP_HOST, + "Activity disconnected from the renderer service. " + + "Destroying its associated screen renderer." + ) + } + + fun reportStatus(pw: PrintWriter, piiHandling: StatusReporter.Pii) { + surfaceController?.reportStatus(pw, piiHandling) + pw.printf("- last template: %s\n", lastTemplate) + } + + /** Shows/hides debug overlay if isVisible is {@code true}/{@code false} respectively. */ + fun showDebugOverlay(isVisible: Boolean) { + templateContext.debugOverlayHandler.isActive = isVisible + } + + private fun surfaceListener(carActivity: ICarAppActivity, carHost: CarHost): ISurfaceListener { + return object : ISurfaceListener.Stub() { + override fun onSurfaceAvailable(surfaceWrapperBundleable: Bundleable) { + val surfaceWrapper = surfaceWrapperBundleable.get() + if (surfaceWrapper !is SurfaceWrapper) { + Log.e( + LogTags.APP_HOST, + "onSurfaceAvailable event invoked with unexpected type: $surfaceWrapper" + ) + // TODO(b/181775931): Better handle error case + return + } + + val width = surfaceWrapper.width + val height = surfaceWrapper.height + ThreadUtils.runOnMain { + val surfaceController = getOrCreateSurfaceController(surfaceWrapper) + surfaceController.setView(templateView, width, height) + this@ScreenRenderer.surfaceController = surfaceController + val surfacePackage = surfaceController.obtainSurfacePackage() + val rendererCallback = RendererCallback(carHost, inputManager) + val insetsListener = InsetsListener(templateView) + + try { + carActivity.setInsetsListener(insetsListener) + carActivity.setSurfacePackage(surfacePackage) + carActivity.registerRendererCallback(rendererCallback) + } catch (e: RemoteException) { + Log.e(LogTags.APP_HOST, "Binder invocation failed", e) + // TODO(b/181775931): Better handle error case + } + + surfaceController.releaseSurfacePackage(surfacePackage) + } + } + + override fun onSurfaceChanged(surfaceWrapperBundleable: Bundleable) { + val surfaceWrapper = surfaceWrapperBundleable.get() + if (surfaceWrapper !is SurfaceWrapper) { + Log.e( + LogTags.APP_HOST, + "onSurfaceChanged event invoked with unexpected type: $surfaceWrapper" + ) + return + } + + ThreadUtils.runOnMain { surfaceController?.relayout(surfaceWrapper) } + } + + override fun onSurfaceDestroyed(surfaceWrapperBundleable: Bundleable) { + val surfaceWrapper = surfaceWrapperBundleable.get() + if (surfaceWrapper !is SurfaceWrapper) { + Log.e( + LogTags.APP_HOST, + "onSurfaceDestroyed event invoked with unexpected type: $surfaceWrapper" + ) + return + } + + ThreadUtils.runOnMain { surfaceController?.releaseSurface() } + } + } + } + + private fun getOrCreateSurfaceController(surfaceWrapper: SurfaceWrapper): SurfaceController { + return if (SUPPORTS_SURFACE_VIEW_HOST_WRAPPER && surfaceWrapper.hostToken != null) { + SurfaceControlViewHostController(context, surfaceWrapper) + } else { + // Reuse old instance for SDK < 30 to avoid flicker (b/187841390) + surfaceController + ?: LegacySurfaceController(context, templateContext) { e -> + Log.e(LogTags.APP_HOST, "LegacySurfaceController error", e) + carActivity.disconnect() + } + } + } + + private fun reloadTemplate() { + ThreadUtils.runOnMain { lastTemplate?.let { templateView.setTemplate(it) } } + } + + private inner class CarAppManagerImpl : CarAppManager { + override fun startCarApp(intent: Intent) { + StartCarAppUtil.validateStartCarAppIntent( + context, + appName.packageName, + intent, + isNavigationApp + ) + carActivity.dispatch { it.startCarApp(intent) } + } + + override fun finishCarApp() { + carActivity.dispatch { it.finishCarApp() } + ThreadUtils.runOnMain { CarHostRepository.remove(appName) } + } + } + + companion object { + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.R) + private val SUPPORTS_SURFACE_VIEW_HOST_WRAPPER = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + private const val MSG_SET_TEMPLATE = 1 + } + + /** A [Handler.Callback] used to process the message queue for the ui controller. */ + private inner class HandlerCallback : Handler.Callback { + private var lastUpdateUptimeMillis = -Long.MAX_VALUE + + override fun handleMessage(msg: Message): Boolean { + if (msg.what == MSG_SET_TEMPLATE) { + // Use SystemClock.uptimeMillis since that is what Handler uses for time. + val currentUptimeMillis: Long = SystemClock.uptimeMillis() + val updateUptimeMillis: Long = lastUpdateUptimeMillis + 1000 + if (updateUptimeMillis > currentUptimeMillis) { + val message: Message = mainHandler.obtainMessage(MSG_SET_TEMPLATE) + message.obj = msg.obj + mainHandler.removeMessages(MSG_SET_TEMPLATE) + mainHandler.sendMessageAtTime(message, updateUptimeMillis) + return true + } + lastUpdateUptimeMillis = currentUptimeMillis + val template: TemplateWrapper = msg.obj as TemplateWrapper + + lastTemplate = template + templateView.setTemplate(template) + return true + } else { + L.w(LogTags.APP_HOST, "Unknown message: %s", msg) + } + return false + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/ScreenRendererRepository.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/ScreenRendererRepository.kt new file mode 100644 index 0000000..5d5f559 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/ScreenRendererRepository.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.renderer + +import android.content.ComponentName +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.templates.host.internal.StatusManager +import com.google.common.collect.ImmutableList +import java.io.PrintWriter +import java.util.concurrent.ConcurrentHashMap +import java.util.function.Supplier + +/** A cache to store instances of active [ScreenRenderer] that is safe for concurrent accesses. */ +object ScreenRendererRepository : StatusReporter { + + // TODO(b/169643103): Change to Guava LRU cache to avoid having potential memory leaks. + private val cache: ConcurrentHashMap<ComponentName, ScreenRenderer> = ConcurrentHashMap() + + init { + StatusManager.addStatusReporter(StatusManager.ReportSection.SCREEN_RENDERES, this) + } + + /** + * Returns the value for the given [key]. If the key is not found in the cache, creates a + * [ScreenRenderer] using the provided [screenRendererProvider], puts its result into the map + * under the given key and returns it. + * + * This method guarantees not to put the value into the map if the key is already there, but the + * [screenRendererProvider] may be invoked even if the key is already in the map. + */ + fun computeIfAbsent( + key: ComponentName, + screenRendererProvider: Supplier<ScreenRenderer> + ): ScreenRenderer { + return cache.getOrPut(key) { screenRendererProvider.get() } + } + + /** Returns the [ScreenRenderer] for the given [key] if available. */ + fun get(key: ComponentName): ScreenRenderer? { + return cache[key] + } + + /** Returns a copy of all the available [ScreenRenderer]s. */ + fun getAll(): ImmutableList<ScreenRenderer> { + return ImmutableList.copyOf(cache.values) + } + + /** Removes the [ScreenRenderer] associated with the given [key] if available. */ + fun remove(key: ComponentName): ScreenRenderer? { + return cache.remove(key) + } + + /** Clears the cache content. */ + fun clear() { + cache.clear() + } + + override fun reportStatus(pw: PrintWriter, piiHandling: StatusReporter.Pii) { + try { + pw.println("ScreenRenderer cache") + pw.printf("- size: %d\n", cache.size) + pw.printf("- screenRenderers: %d\n", cache.size) + for ((name, value) in cache.toSortedMap()) { + pw.println("\n-------------------------------") + pw.printf("App: %s\n", name.flattenToShortString()) + value.reportStatus(pw, piiHandling) + } + } catch (t: Throwable) { + L.e(LogTags.APP_HOST, t, "Failed to produce status report for screen renderer cache") + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/SurfaceControlViewHostController.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/SurfaceControlViewHostController.kt new file mode 100644 index 0000000..c9a47e8 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/SurfaceControlViewHostController.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.renderer + +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Build +import android.view.SurfaceControlViewHost +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.RequiresApi +import androidx.car.app.activity.renderer.surface.SurfaceWrapper +import androidx.car.app.serialization.Bundleable +import com.android.car.libraries.apphost.logging.StatusReporter +import java.io.PrintWriter +import java.lang.IllegalStateException + +/** A simple wrapper around [SurfaceControlViewHost] that conforms to [SurfaceController]. */ +@RequiresApi(Build.VERSION_CODES.R) +class SurfaceControlViewHostController(val context: Context, val surfaceWrapper: SurfaceWrapper) : + SurfaceController { + + private var surfaceControlViewHost: SurfaceControlViewHost + private var width: Int? = null + private var height: Int? = null + + init { + val displayManager = context.getSystemService(DisplayManager::class.java) + val display = displayManager.getDisplay(surfaceWrapper.displayId) + val hostToken = surfaceWrapper.hostToken + surfaceControlViewHost = SurfaceControlViewHost(context, display, hostToken) + } + + /** + * Because we are wrapping the [SurfacePackage] inside a [Bundleable], automatic releasing is not + * happening. Instead it must be released manually using [releaseSurfacePackage] once this value + * has been sent to the remote process. + * + * @see [SurfacePackage] Javadoc on recommendations around releasing this value. + */ + override fun obtainSurfacePackage(): Bundleable { + val surfacePackage = + surfaceControlViewHost.surfacePackage + ?: throw IllegalStateException( + "SurfaceControlViewHost returned a null " + "SurfacePackage, which should never happen" + ) + return Bundleable.create(surfacePackage) + } + + override fun releaseSurfacePackage(value: Bundleable) { + (value.get() as SurfaceControlViewHost.SurfacePackage).release() + } + + override fun relayout(surfaceWrapper: SurfaceWrapper) { + width = surfaceWrapper.width + height = surfaceWrapper.height + surfaceControlViewHost.relayout(surfaceWrapper.width, surfaceWrapper.height) + } + + override fun setView(view: View, width: Int, height: Int) { + this.width = width + this.height = height + + // SurfaceControlViewHost doesn't provide a way to detach the view hierarchy once attached. + // We add an intermediate ViewGroup here so we can detach TemplateView and reuse it in a + // different surface if needed. + val contentView = FrameLayout(context) + contentView.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + (view.parent as ViewGroup?)?.removeView(view) + contentView.addView( + view, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + + surfaceControlViewHost.setView(contentView, width, height) + } + + override fun releaseSurface() { + // No-op. Releasing surface is handled by the surface package. + } + + override fun reportStatus(pw: PrintWriter, piiHandling: StatusReporter.Pii) { + pw.printf("- display id: %d, width: %d, height: %d\n", surfaceWrapper.displayId, width, height) + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/SurfaceController.kt b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/SurfaceController.kt new file mode 100644 index 0000000..d831f84 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/renderer/SurfaceController.kt @@ -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.templates.host.renderer + +import android.view.View +import androidx.car.app.activity.renderer.surface.SurfaceWrapper +import androidx.car.app.serialization.Bundleable +import com.android.car.libraries.apphost.logging.StatusReporter + +/** An interface used for presenters who want to present a surface control host. */ +interface SurfaceController : StatusReporter { + /** + * Returns a surface package object in form of a [Bundleable]. This surface package must be + * released calling [releaseSurfacePackage] + */ + fun obtainSurfacePackage(): Bundleable + + /** Releases a surface package previously obtained with [obtainSurfacePackage] */ + fun releaseSurfacePackage(value: Bundleable) + + /** Relayout the surface using the given [width] and [height]. */ + fun relayout(surfaceWrapper: SurfaceWrapper) + + /** Updates the top level content view with given [view]. */ + fun setView(view: View, width: Int, height: Int) + + /** Releases the surface. Should be called once the surface is destroyed. */ + fun releaseSurface() +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/testing/TestHostApiLevelConfig.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/testing/TestHostApiLevelConfig.java new file mode 100644 index 0000000..34b4203 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/testing/TestHostApiLevelConfig.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.testing; + +import android.content.ComponentName; +import com.android.car.libraries.templates.host.di.HostApiLevelConfig; + +/** A test implementation of {@link HostApiLevelConfig} */ +public class TestHostApiLevelConfig implements HostApiLevelConfig { + + private static final TestHostApiLevelConfig INSTANCE = new TestHostApiLevelConfig(); + + /** Returns a {@link TestHostApiLevelConfig} implementation */ + public static TestHostApiLevelConfig getInstance() { + return INSTANCE; + } + + @Override + public int getHostMinApiLevel(int defaultValue, ComponentName componentName) { + return defaultValue; + } + + @Override + public int getHostMaxApiLevel(int defaultValue, ComponentName componentName) { + return defaultValue; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/testing/TestUxreConfig.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/testing/TestUxreConfig.java new file mode 100644 index 0000000..9472b75 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/testing/TestUxreConfig.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.templates.host.testing; + +import com.android.car.libraries.templates.host.di.UxreConfig; + +/** A test implementation of {@link UxreConfig} */ +public class TestUxreConfig implements UxreConfig { + + private static final TestUxreConfig INSTANCE = new TestUxreConfig(); + + /** Returns a {@link TestUxreConfig} implementation */ + public static TestUxreConfig getInstance() { + return INSTANCE; + } + + @Override + public int getTemplateStackMaxSize(int defaultValue) { + return defaultValue; + } + + @Override + public int getRouteListMaxLength(int defaultValue) { + return defaultValue; + } + + @Override + public int getPaneMaxLength(int defaultValue) { + return defaultValue; + } + + @Override + public int getGridMaxLength(int defaultValue) { + return defaultValue; + } + + @Override + public int getListMaxLength(int defaultValue) { + return defaultValue; + } + + @Override + public int getCarAppDefaultMaxStringLength(int defaultValue) { + return defaultValue; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/TemplateView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/TemplateView.java new file mode 100644 index 0000000..2d76c90 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/TemplateView.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view; + +import static java.lang.Math.max; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Insets; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.car.app.model.Template; +import androidx.car.app.model.TemplateWrapper; +import com.android.car.libraries.apphost.common.DebugOverlayHandler.Observer; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.view.AbstractTemplateView; +import com.android.car.libraries.apphost.view.SurfaceProvider; +import com.android.car.libraries.apphost.view.SurfaceViewContainer; +import com.android.car.libraries.apphost.view.TemplateTransitionManager; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.common.TemplateTransitionManagerImpl; +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 class TemplateView extends AbstractTemplateView implements Observer { + /** + * The {@link SurfaceViewContainer} which holds the surface that 3p apps can use to render custom + * content. + */ + private SurfaceViewContainer mSurfaceViewContainer; + + /** The {@link FrameLayout} container which holds the currently set template. */ + private FrameLayout mTemplateContainer; + + /** The {@link TextView} container which holds debug overlay info. */ + private TextView mDebugOverlayText; + + /** See {@link AbstractTemplateView#getMinimumTopPadding()} */ + private final int mMinimumTopPadding; + + /** {@link TemplateTransitionManager} used by this {@link AbstractTemplateView} implementation */ + private final TemplateTransitionManager mTransitionManager = new TemplateTransitionManagerImpl(); + + /** Creates a new instance of {@link TemplateView}. */ + @SuppressLint("InflateParams") + public static TemplateView create(TemplateContext context) { + TemplateView templateView = + (TemplateView) LayoutInflater.from(context).inflate(R.layout.template_view, null); + context.getDebugOverlayHandler().setObserver(templateView); + return templateView; + } + + public TemplateView(Context context) { + this(context, null); + } + + public TemplateView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public TemplateView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + final int[] themeAttrs = {R.attr.templateStatusBarMinimumTopPadding}; + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mMinimumTopPadding = ta.getDimensionPixelSize(0, 0); + ta.recycle(); + } + + /** + * Returns a {@link SurfaceProvider} which can be used to retrieve the {@link + * android.view.Surface} that 3p apps can use to draw custom content. + */ + @Override + public SurfaceProvider getSurfaceProvider() { + return mSurfaceViewContainer; + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mSurfaceViewContainer = findViewById(R.id.surface_container); + mTemplateContainer = findViewById(R.id.template_container); + mDebugOverlayText = findViewById(R.id.debug_overlay); + } + + @Override + protected SurfaceViewContainer getSurfaceViewContainer() { + return mSurfaceViewContainer; + } + + @Override + protected ViewGroup getTemplateContainer() { + return mTemplateContainer; + } + + @Override + protected int getMinimumTopPadding() { + return mMinimumTopPadding; + } + + @Override + protected TemplateTransitionManager getTransitionManager() { + return mTransitionManager; + } + + @Override + public void setWindowInsets(WindowInsets windowInsets) { + super.setWindowInsets(windowInsets); + + if (mDebugOverlayText == null) { + return; + } + + 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(); + } + + FrameLayout.LayoutParams lp = (LayoutParams) mDebugOverlayText.getLayoutParams(); + lp.setMargins(leftInset, max(topInset, getMinimumTopPadding()), rightInset, bottomInset); + } + + @Override + public void setTemplate(TemplateWrapper templateWrapper) { + super.setTemplate(templateWrapper); + + TemplateContext templateContext = getTemplateContext(); + if (templateContext != null) { + templateContext.getDebugOverlayHandler().resetTemplateDebugOverlay(templateWrapper); + } + } + + @Override + public void entriesUpdated() { + TemplateContext templateContext = getTemplateContext(); + if (templateContext != null) { + setDebugOverlayText(templateContext.getDebugOverlayHandler().getDebugOverlayText()); + setDebugOverlayVisibility(templateContext.getDebugOverlayHandler().isActive()); + } + } + + private void setDebugOverlayText(CharSequence text) { + if (mDebugOverlayText == null) { + return; + } + + mDebugOverlayText.setText(text); + } + + private void setDebugOverlayVisibility(boolean isVisible) { + if (mDebugOverlayText == null) { + return; + } + + mDebugOverlayText.setVisibility(isVisible ? View.VISIBLE : View.GONE); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/animation/AnimationListenerAdapter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/animation/AnimationListenerAdapter.java new file mode 100644 index 0000000..6034f7b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/animation/AnimationListenerAdapter.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.templates.host.view.animation; + +import android.view.animation.Animation; + +/** + * Provides empty implementations of the methods in {@link Animation.AnimationListener} for + * convenience reasons. + */ +public class AnimationListenerAdapter implements Animation.AnimationListener { + @Override + public void onAnimationStart(Animation animation) {} + + @Override + public void onAnimationEnd(Animation animation) {} + + @Override + public void onAnimationRepeat(Animation animation) {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/TemplateTransitionManagerImpl.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/TemplateTransitionManagerImpl.java new file mode 100644 index 0000000..d144770 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/TemplateTransitionManagerImpl.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.templates.host.view.common; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.annotation.SuppressLint; +import android.content.res.TypedArray; +import android.transition.Scene; +import android.transition.Transition; +import android.transition.TransitionInflater; +import android.transition.TransitionManager; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.TemplateTransitionManager; +import com.android.car.libraries.templates.host.R; + +/** Controls transitions between different templates. */ +public class TemplateTransitionManagerImpl implements TemplateTransitionManager { + private static final float TRANSITION_ALPHA_GONE = 0f; + private static final float TRANSITION_ALPHA_VISIBLE = 1f; + + @Override + public void transition( + ViewGroup root, View surface, TemplatePresenter to, @Nullable TemplatePresenter from) { + boolean toFullScreen = to.isFullScreen(); + boolean fromFullScreen = from == null || from.isFullScreen(); + + if (toFullScreen || fromFullScreen) { + transitionDefault(root, surface, to, from); + } else { + transitionBetweenHalfScreenTemplates(root, to); + } + } + + private static void transitionBetweenHalfScreenTemplates(ViewGroup root, TemplatePresenter to) { + Scene endingScene = new Scene(root, to.getView()); + Transition transition = + TransitionInflater.from(root.getContext()) + .inflateTransition(R.transition.half_screen_to_half_screen_transition); + + TransitionManager.go(endingScene, transition); + } + + @SuppressLint("Recycle") + private static void transitionDefault( + ViewGroup root, View surface, TemplatePresenter to, @Nullable TemplatePresenter from) { + @StyleableRes final int[] themeAttrs = {R.attr.templateUpdateAnimationDurationMilliseconds}; + TypedArray ta = root.getContext().obtainStyledAttributes(themeAttrs); + long animationDurationMillis = ta.getInteger(0, 0); + ta.recycle(); + + if (to.usesSurface()) { + surface.setVisibility(View.VISIBLE); + } + + View toView = to.getView(); + View fromView = from == null ? null : from.getView(); + + toView.setAlpha(TRANSITION_ALPHA_GONE); + root.addView(toView); + + toView.animate().alpha(TRANSITION_ALPHA_VISIBLE).setDuration(animationDurationMillis); + + if (fromView != null) { + fromView + .animate() + .alpha(TRANSITION_ALPHA_GONE) + .setDuration(animationDurationMillis) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + if (!to.usesSurface()) { + surface.setVisibility(View.GONE); + } + root.removeView(fromView); + } + }); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/res/transition/half_screen_to_half_screen_transition.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/res/transition/half_screen_to_half_screen_transition.xml new file mode 100644 index 0000000..ca204ef --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/res/transition/half_screen_to_half_screen_transition.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<transitionSet xmlns:android="http://schemas.android.com/apk/res/android" + android:transitionOrdering="together"> + <fade + android:fadingMode="fade_in_out" + android:duration="?templateUpdateAnimationDurationMilliseconds"> + <targets> + <target android:excludeId="@id/map_container" /> + </targets> + </fade> + <changeBounds + android:duration="?templateUpdateAnimationDurationMilliseconds" + android:interpolator="@interpolator/fast_out_slow_in"/> +</transitionSet> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/res/values/ids.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/res/values/ids.xml new file mode 100644 index 0000000..f957662 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/common/res/values/ids.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <item name="map_container" type="id"/> + <item name="content_container" type="id"/> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/AudioRecordThread.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/AudioRecordThread.java new file mode 100644 index 0000000..d2b3997 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/AudioRecordThread.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.common; + +import android.os.ParcelFileDescriptor; +import androidx.annotation.VisibleForTesting; +import com.android.car.libraries.apphost.logging.L; +import com.android.car.libraries.apphost.logging.LogTags; +import java.io.IOException; +import java.io.InputStream; + +/** A class that is used to write bytes to an {@link OutputStream} from an [@link AudioRecord} */ +final class AudioRecordThread extends Thread { + + private static final int AUDIO_RECORD_BUFFER_SIZE_BYTES = 512; + + private final ParcelFileDescriptor.AutoCloseOutputStream mOutputStream; + private final InputStream mInputStream; + private boolean mIsRecording; + private final MicrophoneClosedListener mMicrophoneClosedListener; + + AudioRecordThread( + ParcelFileDescriptor inputDescriptor, + ParcelFileDescriptor outputDescriptor, + MicrophoneClosedListener microphoneClosedListener) { + mOutputStream = new ParcelFileDescriptor.AutoCloseOutputStream(outputDescriptor); + mInputStream = new ParcelFileDescriptor.AutoCloseInputStream(inputDescriptor); + mMicrophoneClosedListener = microphoneClosedListener; + } + + @Override + public void run() { + mIsRecording = true; + L.i(LogTags.TEMPLATE, "Recording START"); + while (mIsRecording) { + + // TODO(b/159207187): Consider using read blocking + byte[] bData = new byte[AUDIO_RECORD_BUFFER_SIZE_BYTES]; + try { + mInputStream.read(bData); + } catch (IOException e) { + L.w(LogTags.TEMPLATE, e, "Recording STOPPED"); + break; + } + if (bData == null) { + L.w(LogTags.TEMPLATE, "Recording STOPPED"); + break; + } + // The task may have been cancelled: + if (isInterrupted()) { + L.d(LogTags.TEMPLATE, "Recording CANCELLED"); + break; + } + + if (bData != null) { + try { + mOutputStream.write(bData, 0, AUDIO_RECORD_BUFFER_SIZE_BYTES); + } catch (IOException e) { + // If we are unable to write bytes to the outputstream + // we close the outputstream and finish recording + L.i(LogTags.TEMPLATE, "Recording DONE"); + break; + } + } + } + + L.d(LogTags.TEMPLATE, "Recording CLEANUP"); + + // TODO(b/159208600): rewrite AudioRecordThread to use a monitor instead of errors to + // communicate + closeRecordingResourcesSafe(); + } + + /** Closes all resources associated with an ongoing recording. */ + public void closeRecordingResourcesSafe() { + if (!mIsRecording) { + return; + } + try { + mOutputStream.close(); + } catch (IOException e) { + L.e(LogTags.TEMPLATE, e, "IOException closing outputstream"); + } finally { + mIsRecording = false; + if (mMicrophoneClosedListener != null) { + + mMicrophoneClosedListener.onMicrophoneClosed(); + } + } + } + + @VisibleForTesting + public MicrophoneClosedListener getMicrophoneClosedListener() { + return mMicrophoneClosedListener; + } + + @VisibleForTesting + public void setRecording(boolean isRecording) { + mIsRecording = isRecording; + } + + public boolean isRecording() { + return mIsRecording; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/CommonTemplateConverter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/CommonTemplateConverter.java new file mode 100644 index 0000000..bc91551 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/CommonTemplateConverter.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.common; + +import android.content.Context; +import androidx.car.app.model.ListTemplate; +import androidx.car.app.model.PaneTemplate; +import androidx.car.app.model.Template; +import androidx.car.app.model.TemplateWrapper; +import com.android.car.libraries.apphost.template.view.model.RowListWrapperTemplate; +import com.android.car.libraries.apphost.view.TemplateConverter; +import com.google.common.collect.ImmutableSet; +import java.util.Collection; + +/** A {@link TemplateConverter} for common templates. */ +public class CommonTemplateConverter implements TemplateConverter { + private static final CommonTemplateConverter INSTANCE = new CommonTemplateConverter(); + private static final ImmutableSet<Class<? extends Template>> SUPPORTED_TEMPLATES = + ImmutableSet.of(PaneTemplate.class, ListTemplate.class); + + /** Returns an instance of CommonTemplateConverter */ + public static CommonTemplateConverter get() { + return INSTANCE; + } + + @Override + public TemplateWrapper maybeConvertTemplate(Context context, TemplateWrapper templateWrapper) { + Template template = templateWrapper.getTemplate(); + if (template instanceof ListTemplate || template instanceof PaneTemplate) { + Template newTemplate = + RowListWrapperTemplate.wrap(context, template, templateWrapper.isRefresh()); + + TemplateWrapper newWrapper = TemplateWrapper.wrap(newTemplate, templateWrapper.getId()); + newWrapper.setRefresh(templateWrapper.isRefresh()); + newWrapper.setCurrentTaskStep(templateWrapper.getCurrentTaskStep()); + return newWrapper; + } + return templateWrapper; + } + + @Override + public Collection<Class<? extends Template>> getSupportedTemplates() { + return SUPPORTED_TEMPLATES; + } + + private CommonTemplateConverter() {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/CommonTemplatePresenterFactory.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/CommonTemplatePresenterFactory.java new file mode 100644 index 0000000..2f30d3d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/CommonTemplatePresenterFactory.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.templates.host.view.presenters.common; + +import androidx.annotation.Nullable; +import androidx.car.app.model.GridTemplate; +import androidx.car.app.model.LongMessageTemplate; +import androidx.car.app.model.MessageTemplate; +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 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.template.view.model.RowListWrapperTemplate; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenterFactory; +import com.google.common.collect.ImmutableSet; +import java.util.Collection; + +/** + * Implementation of a {@link TemplatePresenterFactory} for the production host, responsible for + * providing {@link TemplatePresenter} instances for the set of templates the host supports. + */ +public class CommonTemplatePresenterFactory implements TemplatePresenterFactory { + private static final CommonTemplatePresenterFactory INSTANCE = + new CommonTemplatePresenterFactory(); + private static final ImmutableSet<Class<? extends Template>> SUPPORTED_TEMPLATES = + ImmutableSet.of( + GridTemplate.class, + LongMessageTemplate.class, + MessageTemplate.class, + RowListWrapperTemplate.class, + SearchTemplate.class, + SignInTemplate.class); + + /** Returns an instance of CommonTemplatePresenterFactory */ + public static CommonTemplatePresenterFactory get() { + return INSTANCE; + } + + @Override + @Nullable + public TemplatePresenter createPresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + Class<? extends Template> clazz = templateWrapper.getTemplate().getClass(); + if (GridTemplate.class == clazz) { + return GridTemplatePresenter.create(templateContext, templateWrapper); + } else if (MessageTemplate.class == clazz) { + return MessageTemplatePresenter.create(templateContext, templateWrapper); + } else if (LongMessageTemplate.class == clazz) { + return LongMessageTemplatePresenter.create(templateContext, templateWrapper); + } else if (RowListWrapperTemplate.class == clazz) { + return RowListWrapperTemplatePresenter.create(templateContext, templateWrapper); + } else if (SearchTemplate.class == clazz) { + return SearchTemplatePresenter.create(templateContext, templateWrapper); + } else if (SignInTemplate.class == clazz) { + return SignInTemplatePresenter.create(templateContext, templateWrapper); + } else { + L.w( + LogTags.TEMPLATE, + "Don't know how to create a presenter for template: %s", + clazz.getSimpleName()); + } + return null; + } + + @Override + public Collection<Class<? extends Template>> getSupportedTemplates() { + return SUPPORTED_TEMPLATES; + } + + private CommonTemplatePresenterFactory() {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/GridTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/GridTemplatePresenter.java new file mode 100644 index 0000000..c79feb9 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/GridTemplatePresenter.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.templates.host.view.presenters.common; + +import static android.view.View.VISIBLE; + +import android.annotation.SuppressLint; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.GridTemplate; +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.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.android.car.libraries.templates.host.view.widgets.common.GridWrapper; +import com.android.car.libraries.templates.host.view.widgets.common.HeaderView; + +/** A {@link TemplatePresenter} for {@link GridTemplate} instances. */ +public class GridTemplatePresenter extends AbstractTemplatePresenter { + private final ViewGroup mRootView; + private final HeaderView mHeaderView; + private final ContentView mContentView; + + /** Create a GridTemplatePresenter */ + public static GridTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + GridTemplatePresenter presenter = new GridTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + public View getView() { + return mRootView; + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + protected View getDefaultFocusedView() { + if (mContentView.getVisibility() == VISIBLE) { + return mContentView; + } + return super.getDefaultFocusedView(); + } + + private void update() { + GridTemplate template = (GridTemplate) getTemplate(); + ActionStrip actionStrip = template.getActionStrip(); + GridWrapper gridWrapper; + if (template.isLoading()) { + gridWrapper = + GridWrapper.wrap(null) + .setIsLoading(true) + .setIsRefresh(getTemplateWrapper().isRefresh()) + .build(); + } else { + gridWrapper = + GridWrapper.wrap(template.getSingleList()) + .setIsRefresh(getTemplateWrapper().isRefresh()) + .build(); + } + + mHeaderView.setActionStrip(actionStrip, ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + mHeaderView.setContent(getTemplateContext(), template.getTitle(), template.getHeaderAction()); + mContentView.setGridContent(getTemplateContext(), gridWrapper); + } + + @SuppressLint("InflateParams") + @SuppressWarnings({"methodref.receiver.bound.invalid"}) + private GridTemplatePresenter(TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.LIGHT); + + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext) + .inflate(R.layout.grid_wrapper_template_layout, null); + mContentView = mRootView.findViewById(R.id.grid_content_view); + View contentContainer = mRootView.findViewById(R.id.content_container); + mHeaderView = HeaderView.install(templateContext, contentContainer); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/LongMessageTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/LongMessageTemplatePresenter.java new file mode 100644 index 0000000..afd208f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/LongMessageTemplatePresenter.java @@ -0,0 +1,318 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.common; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static java.util.Objects.requireNonNull; + +import android.content.res.TypedArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.CarText; +import androidx.car.app.model.LongMessageTemplate; +import androidx.car.app.model.TemplateWrapper; +import androidx.recyclerview.widget.RecyclerView; +import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.apphost.view.common.ActionButtonListParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView.Gravity; +import com.android.car.libraries.templates.host.view.widgets.common.HeaderView; +import com.android.car.libraries.templates.host.view.widgets.common.ParkedOnlyFrameLayout; +import com.android.car.ui.recyclerview.CarUiRecyclerView; +import com.android.car.ui.recyclerview.CarUiRecyclerView.OnScrollListener; +import com.android.car.ui.widget.CarUiTextView; +import java.util.List; +import org.checkerframework.checker.initialization.qual.UnknownInitialization; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * An {@link AbstractTemplatePresenter} that shows a scrolling long form message and some actions. + */ +public class LongMessageTemplatePresenter extends AbstractTemplatePresenter { + // TODO(b/183643108): Use a common value for this constant + private static final int MAX_ALLOWED_ACTIONS = 2; + + private final ViewGroup mRootView; + private final HeaderView mHeaderView; + private final CarUiRecyclerView mRecyclerView; + private final ActionButtonListView mStickyActionButtonListView; + private final ActionButtonListView.Gravity mActionButtonListGravity; + private final ActionButtonListParams mActionButtonListParams; + private final String mDisabledActionButtonToastMessage; + + private final LongMessageAdapter mAdapter; + + /** Create a LongMessageTemplatePresenter */ + static LongMessageTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + LongMessageTemplatePresenter presenter = + new LongMessageTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + public View getView() { + return mRootView; + } + + /** Updates the view with current values in the {@link LongMessageTemplate}. */ + private void update() { + LongMessageTemplate template = (LongMessageTemplate) getTemplate(); + ActionStrip actionStrip = template.getActionStrip(); + + // If we have a title or a header action, show the header; hide it otherwise. + CarText title = template.getTitle(); + Action headerAction = template.getHeaderAction(); + if (!CarText.isNullOrEmpty(title) || headerAction != null) { + mHeaderView.setContent(getTemplateContext(), title, headerAction); + } else { + mHeaderView.setContent(getTemplateContext(), null, null); + } + + mHeaderView.setActionStrip(actionStrip, ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + + mAdapter.setMessage(template.getMessage()); + if (mActionButtonListGravity == Gravity.CENTER) { + // In the case of Gravity.CENTER, put the buttons in a row along with the rest of the content. + mAdapter.setActions(template.getActions()); + mStickyActionButtonListView.setVisibility(GONE); + } else { + // If action button list gravity is not Gravity.CENTER, put the buttons in the sticky action + // button list view so they stay on screen at all times. + mAdapter.setActions(null); + mStickyActionButtonListView.setVisibility(VISIBLE); + mStickyActionButtonListView.setActionList( + getTemplateContext(), template.getActions(), mActionButtonListParams); + } + + // If this update is not due to a refresh, scroll back to the top. Template presenters can + // be reused for templates of the same type, so a scroll reset would be needed for the case + // where an app pushes two long message templates in the same flow, for example, or if we at + // some point implement a pool of presenters. + // TODO(b/186244619): Add unit test to cover this path. + if (!getTemplateWrapper().isRefresh()) { + mRecyclerView.scrollToPosition(0); + } + + setActionButtonEnabledState(); + } + + private LongMessageTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.LIGHT); + + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext) + .inflate(R.layout.long_message_template_layout, null); + + mRecyclerView = mRootView.findViewById(R.id.list_view); + + ParkedOnlyFrameLayout contentContainer = mRootView.findViewById(R.id.park_only_container); + mHeaderView = HeaderView.install(templateContext, contentContainer); + contentContainer.setTemplateContext(templateContext); + + mStickyActionButtonListView = mRootView.requireViewById(R.id.sticky_action_button_list_view); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateActionButtonListGravity, R.attr.templatePlainContentBackgroundColor + }; + + TypedArray ta = templateContext.obtainStyledAttributes(themeAttrs); + mActionButtonListGravity = ActionButtonListView.Gravity.values()[ta.getInt(0, 0)]; + @ColorInt int surroundingColor = ta.getColor(1, 0); + ta.recycle(); + + mDisabledActionButtonToastMessage = + templateContext + .getResources() + .getString( + templateContext.getHostResourceIds().getLongMessageTemplateDisabledActionText()); + + mActionButtonListParams = + ActionButtonListParams.builder() + .setMaxActions(MAX_ALLOWED_ACTIONS) + .setOemReorderingAllowed(true) + .setOemColorOverrideAllowed(true) + .setSurroundingColor(surroundingColor) + .build(); + + mAdapter = new LongMessageAdapter(); + mRecyclerView.setAdapter(mAdapter); + + mRecyclerView.addOnScrollListener( + new OnScrollListener() { + @Override + public void onScrolled(CarUiRecyclerView recyclerView, int dx, int dy) { + // no-op + } + + @Override + public void onScrollStateChanged(CarUiRecyclerView recyclerView, int newState) { + if (newState != RecyclerView.SCROLL_STATE_IDLE) { + return; + } + + setActionButtonEnabledState(); + } + }); + // {@link View#OnLayoutChangeListener} is required to disable sticky action buttons on first + // load. + mRecyclerView.addOnLayoutChangeListener( + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> + setActionButtonEnabledState()); + } + + @RequiresNonNull({ + "mRecyclerView", + "mStickyActionButtonListView", + "mDisabledActionButtonToastMessage" + }) + private void setActionButtonEnabledState( + @UnknownInitialization LongMessageTemplatePresenter this) { + if (!mRecyclerView.getView().isAttachedToWindow()) { + return; + } + // Only need to set active state for sticky action buttons since they stay on screen at all + // times. + if (mActionButtonListGravity == Gravity.CENTER) { + return; + } + + boolean enabled = !mRecyclerView.getView().canScrollVertically(/* direction= */ 1); + + if (enabled) { + mStickyActionButtonListView.enableActionButtons(); + } else { + mStickyActionButtonListView.disableActionButtons(mDisabledActionButtonToastMessage); + } + } + + /** Adapter used for rendering the long text and buttons in this template. */ + private class LongMessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { + + static final int ITEM_TYPE_MESSAGE = 1; + static final int ITEM_TYPE_ACTION = 2; + + private String mMessage; + @Nullable private List<Action> mActions; + + public void setMessage(CarText message) { + mMessage = CarTextUtils.toCharSequenceOrEmpty(getTemplateContext(), message).toString(); + notifyDataSetChanged(); + } + + public void setActions(@Nullable List<Action> actions) { + mActions = actions; + notifyDataSetChanged(); + } + + @Override + public int getItemViewType(int position) { + if (position == 0) { + return ITEM_TYPE_MESSAGE; + } else { + return ITEM_TYPE_ACTION; + } + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) { + switch (viewType) { + case ITEM_TYPE_ACTION: + return new ActionsViewHolder( + LayoutInflater.from(getTemplateContext()) + .inflate(R.layout.long_message_action_layout, viewGroup, false)); + + case ITEM_TYPE_MESSAGE: + default: + return new MessageViewHolder( + LayoutInflater.from(getTemplateContext()) + .inflate(R.layout.long_message_layout, viewGroup, false)); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (viewHolder instanceof MessageViewHolder) { + ((MessageViewHolder) viewHolder).bind(mMessage); + } else if (viewHolder instanceof ActionsViewHolder) { + ((ActionsViewHolder) viewHolder).bind(getTemplateContext(), requireNonNull(mActions)); + } + } + + @Override + public int getItemCount() { + return mActions == null ? 1 : 2; + } + + /** ViewHolder for a message list item */ + private class MessageViewHolder extends RecyclerView.ViewHolder { + + private final CarUiTextView mMessage; + + private MessageViewHolder(@NonNull View view) { + super(view); + mMessage = view.requireViewById(R.id.message_text); + } + + private void bind(String message) { + mMessage.setText(message); + } + } + + /** ViewHolder for actions list item */ + private class ActionsViewHolder extends RecyclerView.ViewHolder { + + private final ActionButtonListView mActionButtonListView; + + private ActionsViewHolder(@NonNull View view) { + super(view); + mActionButtonListView = view.requireViewById(R.id.action_button_list_view); + } + + private void bind(TemplateContext templateContext, List<Action> actions) { + if (!actions.isEmpty()) { + mActionButtonListView.setActionList(templateContext, actions, mActionButtonListParams); + mActionButtonListView.setVisibility(VISIBLE); + } else { + mActionButtonListView.setVisibility(GONE); + } + } + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/MessageTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/MessageTemplatePresenter.java new file mode 100644 index 0000000..0fd0c08 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/MessageTemplatePresenter.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.common; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import androidx.annotation.ColorInt; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.MessageTemplate; +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.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.apphost.view.common.ActionButtonListParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.internal.CommonUtils; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView.Gravity; +import com.android.car.libraries.templates.host.view.widgets.common.CarUiTextUtils; +import com.android.car.libraries.templates.host.view.widgets.common.HeaderView; +import com.android.car.libraries.templates.host.view.widgets.common.ViewUtils; +import com.android.car.ui.widget.CarUiTextView; +import java.util.List; + +/** An {@link AbstractTemplatePresenter} that shows an alert message, some actions, and an icon. */ +public class MessageTemplatePresenter extends AbstractTemplatePresenter { + // TODO(b/183643108): Use a common MAX_ALLOWED_ACTIONS between AAOS and AAP + private static final int MAX_ALLOWED_ACTIONS = 2; + + private final ViewGroup mRootView; + private final HeaderView mHeaderView; + private final ViewGroup mProgressContainer; + private final CarUiTextView mMessageTextView; + private final ViewGroup mStackTraceContainer; + private final CarUiTextView mStackTraceView; + private final ImageView mIconView; + private final ActionButtonListView mActionListView; + private final ImageViewParams mImageViewParams; + private final ActionButtonListParams mActionButtonListParams; + private final boolean mIsDebugEnabled; + private final ViewGroup mMessageContainer; + + /** Create a MessageTemplatePresenter */ + public static MessageTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + MessageTemplatePresenter presenter = + new MessageTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + protected View getDefaultFocusedView() { + if (mActionListView.getVisibility() == VISIBLE) { + return mActionListView; + } + return super.getDefaultFocusedView(); + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + public View getView() { + return mRootView; + } + + /** Updates the view with current values in the {@link MessageTemplate}. */ + private void update() { + TemplateContext templateContext = getTemplateContext(); + MessageTemplate template = (MessageTemplate) getTemplate(); + + // If we have a title or a header action, show the header; hide it otherwise. + CarText title = template.getTitle(); + Action headerAction = template.getHeaderAction(); + if (!CarText.isNullOrEmpty(title) || headerAction != null) { + mHeaderView.setContent(getTemplateContext(), title, headerAction); + } else { + mHeaderView.setContent(getTemplateContext(), null, null); + } + + mHeaderView.setActionStrip( + template.getActionStrip(), ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + + // Show a message if we have it, hide it otherwise. + CarText message = template.getMessage(); + if (!CarText.isNullOrEmpty(message)) { + mMessageTextView.setText( + CarUiTextUtils.fromCarText(templateContext, message, mMessageTextView.getMaxLines())); + mMessageTextView.setVisibility(VISIBLE); + + // Allow focus on the message view if there are no actions available. + mMessageTextView.setFocusable(template.getActions().isEmpty()); + } else { + mMessageTextView.setVisibility(GONE); + } + + // The icon and progress indicator are mutually exclusive, next we choose which one to + // display. + boolean isLoading = template.isLoading(); + if (isLoading) { + // If in loading state, show the progress container and hide the icon. + mProgressContainer.setVisibility(VISIBLE); + mIconView.setVisibility(GONE); + } else { + // Not in loading state: hide the progress container and show the icon, if we have one. + mProgressContainer.setVisibility(GONE); + + CarIcon icon = template.getIcon(); + boolean showIcon = icon != null; + if (showIcon) { + showIcon = ImageUtils.setImageSrc(templateContext, icon, mIconView, mImageViewParams); + } + mIconView.setVisibility(showIcon ? VISIBLE : GONE); + } + + // Show the action list if we have it, hide it otherwise. + List<Action> actionList = template.getActions(); + if (!actionList.isEmpty()) { + mActionListView.setActionList(getTemplateContext(), actionList, mActionButtonListParams); + mActionListView.setVisibility(VISIBLE); + } else { + mActionListView.setVisibility(GONE); + } + + // If we can show the debug information, add a button to the action strip that toggles it + // on and off when tapped. + CarText debugMessage = template.getDebugMessage(); + if (mIsDebugEnabled && !CarText.isNullOrEmpty(debugMessage)) { + mStackTraceView.setText(CarTextUtils.toCharSequenceOrEmpty(templateContext, debugMessage)); + mStackTraceContainer.setVisibility(VISIBLE); + addDebugToggle(templateContext); + } else { + mStackTraceContainer.setVisibility(GONE); + } + } + + private void addDebugToggle(TemplateContext templateContext) { + Drawable icon = templateContext.getDrawable(R.drawable.ic_bug_report_grey600_24dp); + mHeaderView.addToggle(icon, this::showTraceView); + } + + private void showTraceView(boolean show) { + mStackTraceContainer.setVisibility(show ? VISIBLE : GONE); + mMessageContainer.setVisibility(show ? GONE : VISIBLE); + } + + @SuppressWarnings("method.invocation.invalid") + private MessageTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.LIGHT); + + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext).inflate(R.layout.message_template_layout, null); + mMessageTextView = mRootView.findViewById(R.id.message_text); + mStackTraceContainer = mRootView.findViewById(R.id.stack_trace_container); + mStackTraceView = mRootView.findViewById(R.id.stack_trace); + mProgressContainer = mRootView.findViewById(R.id.progress_container); + mIconView = mRootView.findViewById(R.id.message_icon); + ViewGroup contentContainer = mRootView.findViewById(R.id.content_container); + mHeaderView = HeaderView.install(templateContext, contentContainer); + mMessageContainer = mRootView.findViewById(R.id.message_container); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateMessageDefaultIconTint, + R.attr.templateLargeImageSizeMin, + R.attr.templateLargeImageSizeMax, + R.attr.templateActionButtonListGravity, + R.attr.templatePlainContentBackgroundColor + }; + + TypedArray ta = templateContext.obtainStyledAttributes(themeAttrs); + @ColorInt int defaultIconTint = ta.getColor(0, 0); + int largeImageSizeMin = ta.getDimensionPixelSize(1, 0); + int largeImageSizeMax = ta.getDimensionPixelSize(2, Integer.MAX_VALUE); + ActionButtonListView.Gravity actionButtonListGravity = + ActionButtonListView.Gravity.values()[ta.getInt(3, 0)]; + @ColorInt int backgroundColor = ta.getColor(4, 0); + ta.recycle(); + + mActionListView = + actionButtonListGravity == Gravity.CENTER + ? mRootView.findViewById(R.id.action_button_list_view) + : mRootView.findViewById(R.id.sticky_action_button_list_view); + + // Progress container size is OEM-customizable. Enforce the size limit here. + ViewUtils.enforceViewSizeLimit(mProgressContainer, largeImageSizeMin, largeImageSizeMax); + + mImageViewParams = + ImageViewParams.builder() + .setDefaultTint(defaultIconTint) + .setBackgroundColor(backgroundColor) + .build(); + mActionButtonListParams = + ActionButtonListParams.builder() + .setMaxActions(MAX_ALLOWED_ACTIONS) + .setOemReorderingAllowed(true) + .setOemColorOverrideAllowed(true) + .setSurroundingColor(backgroundColor) + .build(); + mIsDebugEnabled = CommonUtils.INSTANCE.isDebugEnabled(templateContext); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/MicrophoneClosedListener.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/MicrophoneClosedListener.java new file mode 100644 index 0000000..9294e3c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/MicrophoneClosedListener.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.templates.host.view.presenters.common; + +/** + * A listener which will be notified whenever the microphone is no longer being recorded. Will allow + * for UI to be updated from the watevra host. + */ +public interface MicrophoneClosedListener { + /** Callback for when the microphone is closed. */ + void onMicrophoneClosed(); +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/PresenterUtils.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/PresenterUtils.java new file mode 100644 index 0000000..4f07f3c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/PresenterUtils.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.templates.host.view.presenters.common; + +import android.content.Context; +import android.content.res.Resources; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; + +/** Assorted presenter utilities. */ +public abstract class PresenterUtils { + /** + * Applies the top window insets of the root view of a template to the {@code viewContainer}. + * + * <p>This is needed for templates that use a overlaid view on a background surface, so that the + * status bar is rendered above the surface, and the view container is moved down so that it is + * not drawn under the status bar text. + */ + public static void applyTopWindowInsetsToContainer(int topInset, ViewGroup viewContainer) { + ViewGroup.LayoutParams layoutParams = viewContainer.getLayoutParams(); + if (layoutParams instanceof MarginLayoutParams) { + ((MarginLayoutParams) layoutParams).topMargin = topInset; + viewContainer.setLayoutParams(layoutParams); + } + } + + /** + * Returns the margin value to be applied on the left and right side to set a view's width to be a + * fraction of the screen width. + */ + public static int getAdaptiveMargin(Context context, float containerWidthFraction) { + Resources resources = context.getResources(); + if (resources == null) { + return 0; + } + int screenWidth = resources.getDisplayMetrics().widthPixels; + + float marginFraction = (1.f - containerWidthFraction) / 2; + return (int) (screenWidth * marginFraction); + } + + private PresenterUtils() {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/RowListWrapperTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/RowListWrapperTemplatePresenter.java new file mode 100644 index 0000000..ea049fd --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/RowListWrapperTemplatePresenter.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.common; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.annotation.SuppressLint; +import android.content.res.TypedArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.ColorInt; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +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.template.view.model.RowListWrapper; +import com.android.car.libraries.apphost.template.view.model.RowListWrapperTemplate; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.common.ActionButtonListParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.android.car.libraries.templates.host.view.widgets.common.HeaderView; +import java.util.List; + +/** A {@link TemplatePresenter} for {@link RowListWrapperTemplate} instances. */ +public class RowListWrapperTemplatePresenter extends AbstractTemplatePresenter { + // TODO(b/183643108): Use a common value for this constant + private static final int MAX_ALLOWED_ACTIONS = 2; + + private final ViewGroup mRootView; + private final HeaderView mHeaderView; + private final ContentView mContentView; + private final ActionButtonListView mStickyActionButtonListView; + private final ActionButtonListParams mActionButtonListParams; + + /** Create a RowListWrapperTemplatePresenter */ + public static RowListWrapperTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + RowListWrapperTemplatePresenter presenter = + new RowListWrapperTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + public View getView() { + return mRootView; + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + protected View getDefaultFocusedView() { + if (mContentView.getVisibility() == VISIBLE) { + return mContentView; + } + return super.getDefaultFocusedView(); + } + + private void update() { + RowListWrapperTemplate template = (RowListWrapperTemplate) getTemplate(); + ActionStrip actionStrip = template.getActionStrip(); + RowListWrapper list = template.getList(); + + List<Action> actionList = template.getActionList(); + if (actionList != null && !actionList.isEmpty()) { + mStickyActionButtonListView.setVisibility(VISIBLE); + mStickyActionButtonListView.setActionList( + getTemplateContext(), actionList, mActionButtonListParams); + } else { + mStickyActionButtonListView.setVisibility(GONE); + } + + mHeaderView.setActionStrip(actionStrip, template.getActionsConstraints()); + mHeaderView.setContent(getTemplateContext(), template.getTitle(), template.getHeaderAction()); + + mContentView.setRowListContent(getTemplateContext(), list); + } + + @SuppressLint("InflateParams") + @SuppressWarnings({"methodref.receiver.bound.invalid"}) + private RowListWrapperTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.LIGHT); + + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext) + .inflate(R.layout.row_list_wrapper_template_layout, null); + mContentView = mRootView.findViewById(R.id.content_view); + View contentContainer = mRootView.findViewById(R.id.content_container); + mHeaderView = HeaderView.install(templateContext, contentContainer); + + @StyleableRes final int[] themeAttrs = {R.attr.templatePlainContentBackgroundColor}; + + TypedArray ta = templateContext.obtainStyledAttributes(themeAttrs); + @ColorInt int surroundingColor = ta.getColor(0, 0); + ta.recycle(); + + mActionButtonListParams = + ActionButtonListParams.builder() + .setMaxActions(MAX_ALLOWED_ACTIONS) + .setOemReorderingAllowed(true) + .setOemColorOverrideAllowed(true) + .setSurroundingColor(surroundingColor) + .build(); + + mStickyActionButtonListView = mRootView.requireViewById(R.id.sticky_action_button_list_view); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/SearchTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/SearchTemplatePresenter.java new file mode 100644 index 0000000..fbb5f84 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/SearchTemplatePresenter.java @@ -0,0 +1,239 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.common; + +import static android.text.InputType.TYPE_CLASS_TEXT; +import static android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; +import static android.view.View.VISIBLE; +import static com.android.car.libraries.apphost.template.view.model.RowListWrapper.LIST_FLAGS_RENDER_TITLE_AS_SECONDARY; + +import android.annotation.SuppressLint; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import androidx.annotation.Nullable; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.CarText; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.Row; +import androidx.car.app.model.SearchTemplate; +import androidx.car.app.model.TemplateWrapper; +import com.android.car.libraries.apphost.common.EventManager; +import com.android.car.libraries.apphost.common.EventManager.EventType; +import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.distraction.constraints.RowListConstraints; +import com.android.car.libraries.apphost.input.CarEditable; +import com.android.car.libraries.apphost.input.CarEditableListener; +import com.android.car.libraries.apphost.input.InputManager; +import com.android.car.libraries.apphost.template.view.model.RowListWrapper; +import com.android.car.libraries.apphost.template.view.model.RowWrapper; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.android.car.libraries.templates.host.view.widgets.common.SearchHeaderView; + +/** + * A {@link TemplatePresenter} presenter which controls the {@link InputManager} based on values in + * the {@link SearchTemplate} model provided via {@link #update}. + */ +public class SearchTemplatePresenter extends AbstractTemplatePresenter implements CarEditable { + + private final InputManager mInputManager; + private final ViewGroup mRootView; + private final SearchHeaderView mHeaderView; + private final ContentView mContentView; + + private String mSearchHint; + private final String mDisabledSearchHint; + + private boolean mInputWasActiveOnLastWindowFocus; + + /** Creates a SearchTemplatePresenter */ + public static SearchTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + SearchTemplatePresenter presenter = + new SearchTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + + return presenter; + } + + @Override + public void onStart() { + super.onStart(); + EventManager eventManager = getTemplateContext().getEventManager(); + eventManager.subscribeEvent( + this, + EventType.WINDOW_FOCUS_CHANGED, + () -> { + if (hasWindowFocus()) { + // If the input was active the last time the window was focused, it means + // that the user just dismissed the car screen keyboard. In this case, focus + // on the search result list. + if (mInputWasActiveOnLastWindowFocus) { + mContentView.requestFocus(); + } + } + mInputWasActiveOnLastWindowFocus = mInputManager.isInputActive(); + }); + } + + @Override + public void onStop() { + // TODO(b/182232738): Reenable keyboard listener + // LocationManager locationManager = LocationManager.getInstance(); + // locationManager.removeKeyboardEnabledListener(driveStatusEventListener); + getTemplateContext().getEventManager().unsubscribeEvent(this, EventType.WINDOW_FOCUS_CHANGED); + super.onStop(); + } + + @Override + public void onPause() { + mInputManager.stopInput(); + super.onPause(); + } + + @Override + public View getView() { + return mRootView; + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + protected View getDefaultFocusedView() { + // Hide the cursor by clearing the edit text focus if input is not active + if (!mInputManager.isInputActive()) { + mHeaderView.getSearchBar().clearFocus(); + } + if (mContentView.getVisibility() == VISIBLE) { + return mContentView; + } + + return super.getDefaultFocusedView(); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo editorInfo) { + return mHeaderView.onCreateInputConnection(editorInfo); + } + + @Override + public void setCarEditableListener(CarEditableListener listener) {} + + @Override + public void setInputEnabled(boolean enabled) {} + + private void update() { + SearchTemplate searchTemplate = (SearchTemplate) getTemplate(); + ActionStrip actionStrip = searchTemplate.getActionStrip(); + TemplateContext templateContext = getTemplateContext(); + + // Store the hint so we can set it again when the keyboard is enabled. Use a local variable + // so only one call to getSearchHint and null checker doesn't complain. + String tempSearchHint = searchTemplate.getSearchHint(); + mSearchHint = + tempSearchHint == null + ? templateContext.getString(templateContext.getHostResourceIds().getSearchHintText()) + : tempSearchHint; + updateSearchHint(mHeaderView.getSearchBar().isEnabled()); + + mHeaderView.setActionStrip(actionStrip, ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + + mHeaderView.setAction(searchTemplate.getHeaderAction()); + + ItemList itemList = searchTemplate.getItemList(); + boolean isEmptyList = false; + if (itemList != null && itemList.getItems().isEmpty()) { + // If the list is empty, use the first row to display the no-items message. + itemList = getItemListWithEmptyTextRow(itemList.getNoItemsMessage()); + isEmptyList = true; + } + + RowListWrapper.Builder builder = + RowListWrapper.wrap(templateContext, itemList) + .setIsLoading(searchTemplate.isLoading()) + .setRowFlags(RowWrapper.DEFAULT_UNIFORM_LIST_ROW_FLAGS) + .setRowListConstraints(RowListConstraints.ROW_LIST_CONSTRAINTS_SIMPLE) + .setIsRefresh(getTemplateWrapper().isRefresh()); + if (isEmptyList) { + builder.setListFlags(LIST_FLAGS_RENDER_TITLE_AS_SECONDARY); + } + + mContentView.setRowListContent(templateContext, builder.build()); + } + + private void updateSearchHint(boolean searchEnabled) { + mHeaderView.setHint(searchEnabled ? mSearchHint : mDisabledSearchHint); + } + + /** + * Returns an {@link ItemList} that has the no-item message sent as the text on the first row. + * + * <p>If the input no-item message is {@code null}, a default message will be added instead. + */ + private ItemList getItemListWithEmptyTextRow(@Nullable CarText customNoItemMessage) { + // Set the title to be empty because the no-item message should be rendered as secondary + // text. + String message = + getTemplateContext() + .getString(getTemplateContext().getHostResourceIds().getTemplateListNoItemsText()); + return new ItemList.Builder() + .addItem( + new Row.Builder() + .setTitle( + customNoItemMessage == null ? message : customNoItemMessage.toCharSequence()) + .build()) + .build(); + } + + @SuppressLint("InflateParams") + @SuppressWarnings({"argument.type.incompatible", "method.invocation.invalid"}) + private SearchTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.GONE); + + mInputManager = templateContext.getInputManager(); + + mRootView = + (ViewGroup) LayoutInflater.from(templateContext).inflate(R.layout.search_layout, null); + mContentView = mRootView.findViewById(R.id.content_view); + mDisabledSearchHint = + templateContext.getString(templateContext.getHostResourceIds().getSearchHintDisabledText()); + + SearchTemplate searchTemplate = (SearchTemplate) templateWrapper.getTemplate(); + View contentContainer = mRootView.findViewById(R.id.content_container); + mHeaderView = + SearchHeaderView.install( + templateContext, + contentContainer, + mRootView, + searchTemplate.getInitialSearchText(), + searchTemplate.getSearchCallbackDelegate(), + searchTemplate.isShowKeyboardByDefault()); + int inputType = + mHeaderView.getSearchBar().getInputType() | TYPE_CLASS_TEXT | TYPE_TEXT_FLAG_NO_SUGGESTIONS; + mHeaderView.getSearchBar().setInputType(inputType); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/SignInTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/SignInTemplatePresenter.java new file mode 100644 index 0000000..4a09650 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/SignInTemplatePresenter.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.common; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.content.res.TypedArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import androidx.annotation.ColorInt; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarText; +import androidx.car.app.model.TemplateWrapper; +import androidx.car.app.model.signin.InputSignInMethod; +import androidx.car.app.model.signin.PinSignInMethod; +import androidx.car.app.model.signin.ProviderSignInMethod; +import androidx.car.app.model.signin.QRCodeSignInMethod; +import androidx.car.app.model.signin.SignInTemplate; +import androidx.car.app.model.signin.SignInTemplate.SignInMethod; +import com.android.car.libraries.apphost.common.EventManager; +import com.android.car.libraries.apphost.common.EventManager.EventType; +import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.distraction.constraints.CarColorConstraints; +import com.android.car.libraries.apphost.input.InputManager; +import com.android.car.libraries.apphost.logging.L; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.common.ActionButtonListParams; +import com.android.car.libraries.apphost.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView.Gravity; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonView; +import com.android.car.libraries.templates.host.view.widgets.common.CarUiTextUtils; +import com.android.car.libraries.templates.host.view.widgets.common.ClickableSpanTextContainer; +import com.android.car.libraries.templates.host.view.widgets.common.HeaderView; +import com.android.car.libraries.templates.host.view.widgets.common.InputSignInView; +import com.android.car.libraries.templates.host.view.widgets.common.ParkedOnlyFrameLayout; +import com.android.car.libraries.templates.host.view.widgets.common.PinSignInView; +import com.android.car.libraries.templates.host.view.widgets.common.QRCodeSignInView; +import com.android.car.ui.widget.CarUiTextView; +import java.util.List; + +/** A {@link TemplatePresenter} for {@link SignInTemplate} instances. */ +public class SignInTemplatePresenter extends AbstractTemplatePresenter { + // TODO(b/183643108): Use a common MAX_ALLOWED_ACTIONS between AAOS and AAP + private static final int MAX_ALLOWED_ACTIONS = 2; + private static final CarTextParams ADDITIONAL_TEXT_PARAMS = + CarTextParams.builder().setAllowClickableSpans(true).build(); + + private final InputManager mInputManager; + private final ViewGroup mRootView; + private final HeaderView mHeaderView; + private final LinearLayout mSignInContainer; + private final CarUiTextView mInstructionTextView; + private final ActionButtonView mProviderSignInButton; + private final InputSignInView mInputSignInView; + private final PinSignInView mPinSignInView; + private final QRCodeSignInView mQRCodeSignInView; + private final ClickableSpanTextContainer mAdditionalTextView; + private final ActionButtonListView mActionListView; + private final ParkedOnlyFrameLayout mContentContainer; + private final ProgressBar mProgressBar; + private final String mDisabledInputHint; + private final ActionButtonListParams mActionButtonListParams; + private final CarTextParams mInstructionTextParams; + private boolean mInputWasActiveOnLastWindowFocus; + + /** Create a {@link SignInTemplatePresenter} instance. */ + static SignInTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + SignInTemplatePresenter presenter = + new SignInTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + protected View getDefaultFocusedView() { + if (mProviderSignInButton.getVisibility() == VISIBLE) { + return mProviderSignInButton; + } + if (mInputSignInView.getVisibility() == VISIBLE) { + // Hide the cursor by clearing the edit text focus if input is not active + if (!mInputManager.isInputActive()) { + mInputSignInView.clearEditTextFocus(); + } + } + if (mPinSignInView.getVisibility() == VISIBLE) { + return mPinSignInView; + } + if (mActionListView.getVisibility() == VISIBLE) { + return mActionListView; + } + + return super.getDefaultFocusedView(); + } + + @Override + public void onStart() { + super.onStart(); + EventManager eventManager = getTemplateContext().getEventManager(); + eventManager.subscribeEvent( + this, + EventType.WINDOW_FOCUS_CHANGED, + () -> { + if (hasWindowFocus()) { + // If the input was active the last time the window was focused, it means + // that the user just dismissed the car screen keyboard. In this case, focus + // on the action button list. + if (mInputWasActiveOnLastWindowFocus) { + mActionListView.requestFocus(); + } + } + mInputWasActiveOnLastWindowFocus = mInputManager.isInputActive(); + }); + } + + @Override + public void onStop() { + // TODO(b/182232738): Reenable keyboard listener + // LocationManager locationManager = LocationManager.getInstance(); + // locationManager.removeKeyboardEnabledListener(driveStatusEventListener); + getTemplateContext().getEventManager().unsubscribeEvent(this, EventType.WINDOW_FOCUS_CHANGED); + super.onStop(); + } + + @Override + public void onPause() { + mInputManager.stopInput(); + super.onPause(); + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + public View getView() { + return mRootView; + } + + /** Updates the view with current values in the {@link SignInTemplate}. */ + private void update() { + TemplateContext templateContext = getTemplateContext(); + SignInTemplate template = (SignInTemplate) getTemplate(); + + setHeaderView(templateContext, template); + setProgressBar(template); + setInstructionText(templateContext, template); + setSignInView(templateContext, template); + setAdditionalText(templateContext, template); + setActionListButtons(templateContext, template); + } + + private void setHeaderView(TemplateContext templateContext, SignInTemplate template) { + // If we have a title or a header action, show the header; hide it otherwise. + CarText title = template.getTitle(); + Action headerAction = template.getHeaderAction(); + if (!CarText.isNullOrEmpty(title) || headerAction != null) { + mHeaderView.setContent(templateContext, title, headerAction); + } else { + mHeaderView.setContent(templateContext, null, null); + } + mHeaderView.setActionStrip( + template.getActionStrip(), ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + } + + private void setProgressBar(SignInTemplate template) { + if (template.isLoading()) { + mProgressBar.setVisibility(VISIBLE); + mSignInContainer.setVisibility(GONE); + } else { + mProgressBar.setVisibility(GONE); + mSignInContainer.setVisibility(VISIBLE); + } + } + + private void setInstructionText(TemplateContext templateContext, SignInTemplate template) { + CarText instructions = template.getInstructions(); + if (!CarText.isNullOrEmpty(instructions)) { + mInstructionTextView.setText( + CarUiTextUtils.fromCarText( + templateContext, + instructions, + mInstructionTextParams, + mInstructionTextView.getMaxLines())); + mInstructionTextView.setVisibility(VISIBLE); + } else { + mInstructionTextView.setVisibility(GONE); + } + } + + private void setSignInView(TemplateContext templateContext, SignInTemplate template) { + // Reset the sign-in view + mProviderSignInButton.setVisibility(GONE); + mInputSignInView.setVisibility(GONE); + mPinSignInView.setVisibility(GONE); + mQRCodeSignInView.setVisibility(GONE); + SignInMethod signInMethod = template.getSignInMethod(); + if (signInMethod instanceof ProviderSignInMethod) { + ProviderSignInMethod providerSignInMethod = (ProviderSignInMethod) signInMethod; + Action providerSignInAction = providerSignInMethod.getAction(); + // OEMs cannot overwrite provider method button in Sign in template + mProviderSignInButton.setAction( + templateContext, + providerSignInAction, + ActionButtonListParams.builder().setAllowAppColor(true).build()); + mProviderSignInButton.setVisibility(VISIBLE); + } else if (signInMethod instanceof InputSignInMethod) { + InputSignInMethod inputSignInMethod = (InputSignInMethod) signInMethod; + mInputSignInView.setSignInMethod( + templateContext, + inputSignInMethod, + mInputManager, + mDisabledInputHint, + getTemplateWrapper().isRefresh()); + mInputSignInView.setVisibility(VISIBLE); + } else if (signInMethod instanceof PinSignInMethod) { + PinSignInMethod pinSignInMethod = (PinSignInMethod) signInMethod; + mPinSignInView.setText( + CarUiTextUtils.fromCarText( + templateContext, pinSignInMethod.getPinCode(), mPinSignInView.getMaxLines())); + mPinSignInView.setVisibility(VISIBLE); + } else if (signInMethod instanceof QRCodeSignInMethod) { + QRCodeSignInMethod qrCodeSignInMethod = (QRCodeSignInMethod) signInMethod; + mQRCodeSignInView.setQRCodeSignInMethod(templateContext, qrCodeSignInMethod); + mQRCodeSignInView.setVisibility(VISIBLE); + } else { + L.w(LogTags.TEMPLATE, "Unknown sign in method: %s", signInMethod); + } + } + + private void setAdditionalText(TemplateContext templateContext, SignInTemplate template) { + CarText additionalText = template.getAdditionalText(); + + if (!CarText.isNullOrEmpty(additionalText)) { + mAdditionalTextView.setText( + CarTextUtils.toCharSequenceOrEmpty( + templateContext, additionalText, ADDITIONAL_TEXT_PARAMS)); + mAdditionalTextView.setVisibility(VISIBLE); + } else { + mAdditionalTextView.setVisibility(GONE); + } + } + + private void setActionListButtons(TemplateContext templateContext, SignInTemplate template) { + List<Action> actionList = template.getActions(); + if (!actionList.isEmpty()) { + mActionListView.setActionList(templateContext, actionList, mActionButtonListParams); + mActionListView.setVisibility(VISIBLE); + } else { + mActionListView.setVisibility(GONE); + } + } + + @SuppressWarnings("nullness:method.invocation") + private SignInTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.LIGHT); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateActionButtonListGravity, R.attr.templatePlainContentBackgroundColor + }; + + TypedArray ta = templateContext.obtainStyledAttributes(themeAttrs); + ActionButtonListView.Gravity actionButtonListGravity = + ActionButtonListView.Gravity.values()[ta.getInt(0, 0)]; + @ColorInt int backgroundColor = ta.getColor(1, 0); + ta.recycle(); + + mInputManager = templateContext.getInputManager(); + mDisabledInputHint = + templateContext.getString(templateContext.getHostResourceIds().getSearchHintDisabledText()); + mActionButtonListParams = + ActionButtonListParams.builder() + .setMaxActions(MAX_ALLOWED_ACTIONS) + .setOemReorderingAllowed(false) + .setOemColorOverrideAllowed(false) + .setSurroundingColor(backgroundColor) + .build(); + mInstructionTextParams = + CarTextParams.builder() + .setColorSpanConstraints(CarColorConstraints.STANDARD_ONLY) + .setBackgroundColor(backgroundColor) + .build(); + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext).inflate(R.layout.sign_in_template_layout, null); + mSignInContainer = mRootView.findViewById(R.id.sign_in_container); + mInstructionTextView = mRootView.findViewById(R.id.instruction_text); + mProviderSignInButton = mRootView.findViewById(R.id.provider_sign_in_button); + mInputSignInView = mRootView.findViewById(R.id.input_sign_in_view); + mPinSignInView = mRootView.findViewById(R.id.pin_sign_in_view); + mQRCodeSignInView = mRootView.findViewById(R.id.qr_code_sign_in_view); + mAdditionalTextView = mRootView.findViewById(R.id.additional_text); + mContentContainer = mRootView.findViewById(R.id.park_only_container); + mHeaderView = HeaderView.install(templateContext, mContentContainer); + mContentContainer.setTemplateContext(templateContext); + mProgressBar = mRootView.findViewById(R.id.sign_in_progress_bar); + mActionListView = + actionButtonListGravity == Gravity.CENTER + ? mRootView.findViewById(R.id.action_button_list_view) + : mRootView.findViewById(R.id.sticky_action_button_list_view); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_bug_report_grey600_24dp.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_bug_report_grey600_24dp.xml new file mode 100644 index 0000000..b471d0d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_bug_report_grey600_24dp.xml @@ -0,0 +1,10 @@ +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:height="24dp" + android:viewportHeight="24" + android:viewportWidth="24" + android:width="24dp"> + <path + android:fillColor="@color/default_gray_600" + android:pathData="M20,10L20,8h-2.81c-0.45,-0.78 -1.07,-1.46 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17c-0.03,-0.01 -0.05,-0.01 -0.08,-0.01 -0.16,-0.04 -0.32,-0.06 -0.49,-0.09l-0.17,-0.03C12.46,5.02 12.23,5 12,5c-0.49,0 -0.97,0.07 -1.42,0.18l0.02,-0.01L8.41,3 7,4.41l1.62,1.63h0.01c-0.75,0.5 -1.37,1.18 -1.82,1.96L4,8v2h2.09c-0.06,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10zM16,15c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4v-4c0,-2.21 1.79,-4 4,-4s4,1.79 4,4v4zM10,14h4v2h-4zM10,10h4v2h-4z"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_mic_black_48dp.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_mic_black_48dp.xml new file mode 100644 index 0000000..e1ff4ac --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_mic_black_48dp.xml @@ -0,0 +1,4 @@ +<vector android:height="48dp" android:viewportHeight="24" + android:viewportWidth="24" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#000000" android:pathData="M12,14c1.66,0 3,-1.34 3,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5L5,11c0,3.53 2.61,6.43 6,6.92L11,21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_report_problem_black_48dp.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_report_problem_black_48dp.xml new file mode 100644 index 0000000..ee5bc0e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_report_problem_black_48dp.xml @@ -0,0 +1,4 @@ +<vector android:height="48dp" android:viewportHeight="24" + android:viewportWidth="24" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#000000" android:pathData="M12,5.99L19.53,19L4.47,19L12,5.99M12,2L1,21h22L12,2zM13,16h-2v2h2v-2zM13,10h-2v4h2v-4z"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_volume_up_black_48dp.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_volume_up_black_48dp.xml new file mode 100644 index 0000000..c58cae9 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/drawable/ic_volume_up_black_48dp.xml @@ -0,0 +1,4 @@ +<vector android:height="48dp" android:viewportHeight="24" + android:viewportWidth="24" android:width="48dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#000000" android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM10,8.83v6.34L7.83,13L5,13v-2h2.83L10,8.83zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77 0,-4.28 -2.99,-7.86 -7,-8.77z"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/grid_wrapper_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/grid_wrapper_template_layout.xml new file mode 100644 index 0000000..3795876 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/grid_wrapper_template_layout.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + style="?attr/templatePlainContentContainerStyle" + android:orientation="vertical"> + + <!-- A container for the contents of the screen, used as the container for + the header view. --> + <LinearLayout + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <include + android:id="@+id/grid_content_view" + layout="@layout/content_view" + android:layout_marginTop="@dimen/car_ui_padding_3" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1"/> + </LinearLayout> +</FrameLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_action_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_action_layout.xml new file mode 100644 index 0000000..8641f81 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_action_layout.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" > + <include + android:id="@+id/action_button_list_view" + layout="@layout/action_button_list_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/car_app_ui_text_to_control_spacing_vertical" + android:layout_marginBottom="@dimen/template_padding_2" /> +</FrameLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_layout.xml new file mode 100644 index 0000000..f087180 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_layout.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<CarUiTextView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/message_text" + style="?templateMessageLongTextStyle" + android:layout_gravity="start" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:foreground="@drawable/no_content_view_focus_ring" /> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_template_layout.xml new file mode 100644 index 0000000..68c5af4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/long_message_template_layout.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + style="?attr/templatePlainContentContainerStyle" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- A container for the contents of the screen, used as the container for + the header view. --> + <com.android.car.libraries.templates.host.view.widgets.common.ParkedOnlyFrameLayout + android:id="@+id/park_only_container" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <com.android.car.ui.FocusArea + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/list_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:clickable="true" + android:paddingTop="@dimen/template_padding_3" + android:clipToPadding="true" + app:layoutStyle="linear"/> + + <include + android:id="@+id/sticky_action_button_list_view" + layout="@layout/action_button_list_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateStickyButtonsVerticalSpacing" + android:layout_marginBottom="?templateStickyButtonsVerticalSpacing" + android:visibility="gone" /> + </com.android.car.ui.FocusArea> + </com.android.car.libraries.templates.host.view.widgets.common.ParkedOnlyFrameLayout> +</FrameLayout> + diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/message_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/message_template_layout.xml new file mode 100644 index 0000000..caca61d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/message_template_layout.xml @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="?attr/templatePlainContentContainerStyle" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <!-- A container for the contents of the screen, used so we can implement + margins that adapt to different screen sizes. Used as a container for + the header view.--> + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <!-- A container for the template elements that is centered in the screen. --> + <com.android.car.ui.FocusArea + android:id="@+id/message_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:gravity="center" + android:orientation="vertical" + android:layout_marginHorizontal="?templatePlainContentHorizontalPadding" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@id/sticky_action_button_focus_area" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + tools:ignore="UselessParent"> + + <!-- A progress indicator shown in place of the icon if the message + template is in loading state--> + <FrameLayout + android:id="@+id/progress_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:focusable="true" + android:visibility="gone"> + <com.android.car.libraries.templates.host.view.widgets.common.CarProgressBar + android:id="@+id/progress" + style="?templateLoadingSpinnerStyle" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinSize="?templateLargeImageSizeMin" + app:imageMaxSize="?templateLargeImageSizeMax" + android:layout_gravity="center" /> + </FrameLayout> + + <!-- An icon shown on top of the contents. --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/message_icon" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinWidth="?templateLargeImageSizeMin" + app:imageMaxWidth="?templateLargeImageSizeMax" + app:imageMinHeight="?templateLargeImageSizeMin" + app:imageMaxHeight="?templateLargeImageSizeMax" + android:visibility="gone" + tools:ignore="ContentDescription" /> + + <!-- The title displayed below the icon. --> + <CarUiTextView + android:id="@+id/message_text" + style="?templateMessageTitleTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginTop="?templateMessageTitleTopSpacing" + android:foreground="@drawable/no_content_view_focus_ring" /> + + <include + android:id="@+id/action_button_list_view" + layout="@layout/action_button_list_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateMessageButtonsTopSpacing" + android:visibility="gone" /> + </com.android.car.ui.FocusArea> + + <!-- Stack trace section, shown only in debug when clicking on the + action to view it. --> + <ScrollView + android:id="@+id/stack_trace_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/template_padding_4" + android:layout_marginBottom="@dimen/template_padding_1" + android:layout_marginStart="@dimen/template_width_keyline_2" + android:layout_marginEnd="@dimen/template_width_keyline_2" + android:padding="@dimen/template_padding_1" + android:focusable="false" + android:layout_gravity="top" + android:visibility="gone" + android:background="?templateDebugMessageBackgroundColor" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + tools:ignore="RtlHardcoded"> + + <HorizontalScrollView + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <CarUiTextView + android:id="@+id/stack_trace" + style="?templateMessageDebugTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + </HorizontalScrollView> + </ScrollView> + <com.android.car.ui.FocusArea + android:id="@+id/sticky_action_button_focus_area" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + <include + android:id="@+id/sticky_action_button_list_view" + layout="@layout/action_button_list_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateStickyButtonsVerticalSpacing" + android:layout_marginBottom="?templateStickyButtonsVerticalSpacing" + android:visibility="gone" /> + </com.android.car.ui.FocusArea> + </androidx.constraintlayout.widget.ConstraintLayout> +</LinearLayout> + diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/row_list_wrapper_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/row_list_wrapper_template_layout.xml new file mode 100644 index 0000000..84669a8 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/row_list_wrapper_template_layout.xml @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + style="?attr/templatePlainContentContainerStyle" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <!-- A container for the contents of the screen, used as the container for + the header view. --> + <LinearLayout + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <include + android:id="@+id/content_view" + layout="@layout/content_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1"/> + + <com.android.car.ui.FocusArea + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + <include + android:id="@+id/sticky_action_button_list_view" + layout="@layout/action_button_list_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateStickyButtonsVerticalSpacing" + android:layout_marginBottom="?templateStickyButtonsVerticalSpacing" + android:visibility="gone" /> + </com.android.car.ui.FocusArea> + </LinearLayout> +</LinearLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/search_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/search_layout.xml new file mode 100644 index 0000000..3bfd482 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/search_layout.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + style="?templatePlainContentContainerStyle" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <include + android:id="@+id/content_view" + layout="@layout/content_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1"/> + </LinearLayout> +</LinearLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/sign_in_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/sign_in_template_layout.xml new file mode 100644 index 0000000..a8c56b7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/sign_in_template_layout.xml @@ -0,0 +1,140 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="?attr/templatePlainContentContainerStyle" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <!-- A container for the contents of the screen, used so we can implement + margins that adapt to different screen sizes. Used as a container for + the header view. --> + <com.android.car.libraries.templates.host.view.widgets.common.ParkedOnlyFrameLayout + android:id="@+id/park_only_container" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <!-- This FrameLayout's visibility is modified only by + ParkedOnlyFrameLayout --> + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + <!-- The loading spinner. --> + <com.android.car.libraries.templates.host.view.widgets.common.CarProgressBar + android:id="@+id/sign_in_progress_bar" + style="?templateLoadingSpinnerStyle" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinSize="?templateLargeImageSizeMin" + app:imageMaxSize="?templateLargeImageSizeMax" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + android:visibility="gone"/> + + <!-- A container for the template elements that is centered in the + screen. --> + <LinearLayout + android:id="@+id/sign_in_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + style="?templateSignInContainerStyle" + android:orientation="vertical" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@+id/sticky_action_button_list_view" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + tools:ignore="UselessParent"> + + <!-- The instruction text. --> + <CarUiTextView + android:id="@+id/instruction_text" + style="?templateSignInInstructionTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:focusable="true" + android:layout_marginTop="@dimen/template_padding_3" + android:foreground="@drawable/no_content_view_focus_ring" /> + + <!-- Provider Sign In Method --> + <include + android:id="@+id/provider_sign_in_button" + layout="@layout/sign_in_button_view" + android:layout_width="wrap_content" + android:layout_height="?templateActionButtonHeight" + android:layout_marginTop="@dimen/car_app_ui_text_to_control_spacing_vertical" + android:visibility="gone" /> + + <!-- Input Sign In Method --> + <include + android:id="@+id/input_sign_in_view" + layout="@layout/input_sign_in_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/car_app_ui_text_to_control_spacing_vertical" + android:visibility="gone" /> + + <!-- PIN Sign In Method --> + <include + android:id="@+id/pin_sign_in_view" + layout="@layout/pin_sign_in_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/car_app_ui_text_to_control_spacing_vertical" + android:visibility="gone" /> + + <!-- QR Code Sign In Method --> + <include + android:id="@+id/qr_code_sign_in_view" + layout="@layout/qr_code_sign_in_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/car_app_ui_text_to_control_spacing_vertical" + android:visibility="gone" /> + + <include + android:id="@+id/additional_text" + layout="@layout/clickable_span_text_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/car_app_ui_control_to_text_spacing_vertical" /> + + <include + android:id="@+id/action_button_list_view" + layout="@layout/action_button_list_view" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/car_app_ui_text_to_secondary_control_spacing_vertical" + android:visibility="gone" /> + </LinearLayout> + <include + android:id="@+id/sticky_action_button_list_view" + layout="@layout/action_button_list_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateStickyButtonsVerticalSpacing" + android:layout_marginBottom="?templateStickyButtonsVerticalSpacing" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + android:visibility="gone" /> + </androidx.constraintlayout.widget.ConstraintLayout> + </com.android.car.libraries.templates.host.view.widgets.common.ParkedOnlyFrameLayout> +</LinearLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/voice_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/voice_layout.xml new file mode 100644 index 0000000..11b7e8e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/common/res/layout/voice_layout.xml @@ -0,0 +1,71 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="?attr/templatePlainContentContainerStyle" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <include + layout="@layout/card_header_layout" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + +<!-- A container for the contents of the screen, used so we can implement + margins that adapt to different screen sizes. --> + <FrameLayout + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- A container for the template elements that is centered in the screen. We ignore + UseCompoundDrawables since are image needs an id to change with the VoiceTemplate state --> + <LinearLayout + android:id="@+id/button_with_description" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:gravity="center" + android:orientation="vertical" + tools:ignore="UselessParent,UseCompoundDrawables"> + + <!-- An icon shown on top of the contents. --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/voice_button" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinWidth="?templateLargeImageSizeMin" + app:imageMaxWidth="?templateLargeImageSizeMax" + app:imageMinHeight="?templateLargeImageSizeMin" + app:imageMaxHeight="?templateLargeImageSizeMax" + tools:ignore="ContentDescription"/> + + <!-- The title displayed below the icon. --> + <CarUiTextView + android:id="@+id/state_description" + style="?templateMessageTitleTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginTop="?templateMessageTitleTopSpacing"/> + </LinearLayout> + </FrameLayout> +</LinearLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/MapsTemplatePresenterFactory.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/MapsTemplatePresenterFactory.java new file mode 100644 index 0000000..7ab0e12 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/MapsTemplatePresenterFactory.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.maps; + +import android.content.Context; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.PlaceListMapTemplate; +import androidx.car.app.model.Template; +import androidx.car.app.model.TemplateWrapper; +import androidx.lifecycle.Lifecycle.State; +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.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenterFactory; +import com.android.car.libraries.apphost.view.widget.map.AbstractMapViewContainer; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.di.MapViewContainerFactory; +import com.google.common.collect.ImmutableSet; +import dagger.hilt.android.scopes.ServiceScoped; +import java.util.Collection; +import javax.inject.Inject; + +/** + * Implementation of a {@link TemplatePresenterFactory} for the production host, responsible for + * providing {@link TemplatePresenter} instances for the set of templates the host supports. + */ +@ServiceScoped +public class MapsTemplatePresenterFactory implements TemplatePresenterFactory { + + private static final ImmutableSet<Class<? extends Template>> SUPPORTED_TEMPLATES = + ImmutableSet.of(PlaceListMapTemplate.class); + + /** Boolean to trigger an one-time preload of MapView rendering code. */ + private static boolean sMapViewPreloaded; + + private final MapViewContainerFactory mMapViewContainerFactory; + + @Inject + MapsTemplatePresenterFactory(MapViewContainerFactory mapViewContainerFactory) { + mMapViewContainerFactory = mapViewContainerFactory; + } + + @VisibleForTesting + public static boolean isMapViewPreloaded() { + return sMapViewPreloaded; + } + + /** + * Performance optimization: preload some of the MapView rendering code that requires one-time + * static initialization. + * + * <p>This helps to speed up MapView being loaded when the {@link PlaceListMapTemplate} is + * actually used. + */ + @MainThread + public void preloadMapView(Context context) { + if (sMapViewPreloaded) { + L.d(LogTags.TEMPLATE, "MapView previously preloaded. Skipping."); + return; + } + + AbstractMapViewContainer mapViewContainer = + mMapViewContainerFactory.create(context, R.style.Theme_Template); + // onCreate triggers the mapView.getMapAsync call to initialize the mapView. + mapViewContainer.getLifecycleRegistry().setCurrentState(State.CREATED); + // make sure the mapView is properly cleaned up to avoid any possible leaks. + mapViewContainer.getLifecycleRegistry().setCurrentState(State.DESTROYED); + sMapViewPreloaded = true; + } + + @Override + @Nullable + public TemplatePresenter createPresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + Class<? extends Template> clazz = templateWrapper.getTemplate().getClass(); + + if (PlaceListMapTemplate.class == clazz) { + return PlaceListMapTemplatePresenter.create( + templateContext, templateWrapper, mMapViewContainerFactory); + } else { + L.w( + LogTags.TEMPLATE, + "Don't know how to create a presenter for template: %s", + clazz.getSimpleName()); + } + return null; + } + + @Override + public Collection<Class<? extends Template>> getSupportedTemplates() { + return SUPPORTED_TEMPLATES; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/PlaceListMapTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/PlaceListMapTemplatePresenter.java new file mode 100644 index 0000000..a5205c6 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/PlaceListMapTemplatePresenter.java @@ -0,0 +1,334 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.maps; + +import static android.view.View.VISIBLE; +import static java.util.Objects.requireNonNull; + +import android.annotation.SuppressLint; +import android.graphics.Rect; +import android.transition.TransitionInflater; +import android.transition.TransitionManager; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.PlaceListMapTemplate; +import androidx.car.app.model.TemplateWrapper; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.Lifecycle.State; +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.StatusBarManager.StatusBarState; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.distraction.constraints.RowListConstraints; +import com.android.car.libraries.apphost.template.view.model.RowListWrapper; +import com.android.car.libraries.apphost.template.view.model.RowWrapper; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.widget.map.AbstractMapViewContainer; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.di.MapViewContainerFactory; +import com.android.car.libraries.templates.host.view.widgets.common.ActionStripView; +import com.android.car.libraries.templates.host.view.widgets.common.CardHeaderView; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.google.common.collect.ImmutableList; + +/** A {@link TemplatePresenter} that shows a map view with pins for locations. */ +public class PlaceListMapTemplatePresenter extends AbstractTemplatePresenter { + private final ViewGroup mRootView; + private final ViewGroup mCardContainer; + private final ActionStripView mActionStripView; + private final CardHeaderView mHeaderView; + private final ContentView mContentView; + // This is lazy-initiated during every onStart instead of just in the ctor. For some reason the + // map view is not laid out when the user exits the app and comes back to the template, + // preventing the map from updating from place changes. Therefore as a workaround we just + // re-create the map evertime the user comes back. See b/178606261 for more details. + @Nullable private AbstractMapViewContainer mMapContainer; + private final OnGlobalLayoutListener mGlobalLayoutListener; + private final MapViewContainerFactory mMapViewContainerFactory; + + /** Creates a {@link PlaceListMapTemplatePresenter}. */ + public static PlaceListMapTemplatePresenter create( + TemplateContext templateContext, + TemplateWrapper templateWrapper, + MapViewContainerFactory mapViewContainerFactory) { + PlaceListMapTemplatePresenter presenter = + new PlaceListMapTemplatePresenter( + templateContext, templateWrapper, mapViewContainerFactory); + presenter.update(); + return presenter; + } + + @VisibleForTesting + @Nullable + public AbstractMapViewContainer getMapContainer() { + return mMapContainer; + } + + @Override + public void onCreate() { + super.onCreate(); + updateMapContainerLifeCycle(State.CREATED); + } + + @Override + public void onDestroy() { + updateMapContainerLifeCycle(State.DESTROYED); + super.onDestroy(); + } + + @Override + public void onStart() { + super.onStart(); + updateMapContainerLifeCycle(State.STARTED); + + EventManager eventManager = getTemplateContext().getEventManager(); + eventManager.subscribeEvent(this, EventType.CONFIGURATION_CHANGED, this::refreshViews); + eventManager.subscribeEvent(this, EventType.PLACE_LIST, this::updatePlaces); + + // Instantiating the map views during onStart as otherwise the map may not get laid out + // properly. See b/178606261 for more details. + refreshViews(); + } + + @Override + public void onStop() { + updateMapContainerLifeCycle(State.CREATED); + TemplateContext templateContext = getTemplateContext(); + + // Clear the list of places when transitioning out of this presenter. + // This prevents a flow when the app enters this presenter again, it will temporarily show + // the previous markers that were set. + requireNonNull(templateContext.getAppHostService(LocationMediator.class)) + .setCurrentPlaces(ImmutableList.of()); + + templateContext.getEventManager().unsubscribeEvent(this, EventType.CONFIGURATION_CHANGED); + templateContext.getEventManager().unsubscribeEvent(this, EventType.PLACE_LIST); + + super.onStop(); + } + + @Override + public void onPause() { + getView().getViewTreeObserver().removeOnGlobalLayoutListener(mGlobalLayoutListener); + updateMapContainerLifeCycle(State.STARTED); + super.onPause(); + } + + @Override + public void onResume() { + super.onResume(); + getView().getViewTreeObserver().addOnGlobalLayoutListener(mGlobalLayoutListener); + updateMapContainerLifeCycle(State.RESUMED); + } + + @Override + public View getView() { + return mRootView; + } + + @Override + protected View getDefaultFocusedView() { + if (mContentView.getVisibility() == VISIBLE) { + return mContentView; + } + return super.getDefaultFocusedView(); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent keyEvent) { + // Move between the card view and the action button view on left or right rotary nudge. + if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { + // If the focus is in the card view (back button or row list), request focus in the + // action strip. + if (moveFocusIfPresent( + ImmutableList.of(mCardContainer), ImmutableList.of(mActionStripView))) { + return true; + } + } else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { + // Request focus on the content view so that the first row in the list will take focus. + if (moveFocusIfPresent(ImmutableList.of(mActionStripView), ImmutableList.of(mContentView))) { + return true; + } + } + return super.onKeyUp(keyCode, keyEvent); + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + public boolean handlesTemplateChangeAnimation() { + // PlaceListMapTemplate has special behavior since we don't want to destroy the MapView for + // a refresh, and it handles the update motion correctly internally. + return true; + } + + @Override + public boolean isFullScreen() { + return false; + } + + /** Updates the locations in the map */ + private void update() { + PlaceListMapTemplate mapTemplate = (PlaceListMapTemplate) getTemplate(); + TemplateContext templateContext = getTemplateContext(); + + if (mapTemplate.isLoading()) { + // Clear the last list of places if we are in loading state so that we are not showing + // stale markers that do not correspond to the current list. + requireNonNull(templateContext.getAppHostService(LocationMediator.class)) + .setCurrentPlaces(ImmutableList.of()); + } + + TransitionManager.beginDelayedTransition( + mRootView, + TransitionInflater.from(templateContext) + .inflateTransition(R.transition.map_template_transition)); + + mHeaderView.setContent( + templateContext, + mapTemplate.getTitle(), + mapTemplate.getHeaderAction(), + mapTemplate.getOnContentRefreshDelegate()); + + ItemList itemList = mapTemplate.getItemList(); + RowListWrapper rowListWrapper = + RowListWrapper.wrap(templateContext, itemList) + .setIsLoading(mapTemplate.isLoading()) + .setRowFlags(RowWrapper.DEFAULT_UNIFORM_LIST_ROW_FLAGS) + .setRowListConstraints(RowListConstraints.ROW_LIST_CONSTRAINTS_SIMPLE) + .setIsRefresh(getTemplateWrapper().isRefresh()) + .setIsHalfList(true) + .build(); + mContentView.setRowListContent(templateContext, rowListWrapper); + + updateMapSettings(mapTemplate); + updateActionStrip(mapTemplate.getActionStrip()); + } + + // TODO(b/159908673): add tests for the lifecycle management logic in here. + private void refreshViews() { + // Destroy the previous MapView based on this presenter's currently lifecycle events. + AbstractMapViewContainer previousMapContainer = mMapContainer; + if (previousMapContainer != null) { + previousMapContainer.getLifecycleRegistry().setCurrentState(State.DESTROYED); + mRootView.removeView(previousMapContainer); + } + + Lifecycle lifecycle = getLifecycle(); + if (lifecycle.getCurrentState() == State.DESTROYED) { + // View already destroyed. Don't bother refreshing the views. + return; + } + + mMapContainer = mMapViewContainerFactory.create(getTemplateContext(), R.style.Theme_Template); + + if (mMapContainer != null) { + mMapContainer.setTemplateContext(getTemplateContext()); + mMapContainer.setId(R.id.map_container); + mRootView.addView(mMapContainer, 0); + + // Update the new MapView's lifecycle events to match this presenter's, as that is + // required for the map instance to be initiated and shown. + mMapContainer.getLifecycleRegistry().setCurrentState(lifecycle.getCurrentState()); + } + + PlaceListMapTemplate mapTemplate = (PlaceListMapTemplate) getTemplate(); + updateMapSettings(mapTemplate); + updateActionStrip(mapTemplate.getActionStrip()); + } + + private void updateMapSettings(PlaceListMapTemplate mapTemplate) { + AbstractMapViewContainer container = mMapContainer; + if (container != null) { + container.setCurrentLocationEnabled(mapTemplate.isCurrentLocationEnabled()); + container.setAnchor(mapTemplate.getAnchor()); + updatePlaces(); + } + } + + private void updatePlaces() { + AbstractMapViewContainer container = mMapContainer; + if (container != null) { + container.setPlaces( + requireNonNull(getTemplateContext().getAppHostService(LocationMediator.class)) + .getCurrentPlaces()); + } + } + + private void updateActionStrip(@Nullable ActionStrip actionStrip) { + mActionStripView.setActionStrip( + getTemplateContext(), actionStrip, ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + } + + private void updateMapContainerLifeCycle(State state) { + AbstractMapViewContainer container = mMapContainer; + // TODO(b/180162594): Use ifNotNull when available. + if (container != null) { + container.getLifecycleRegistry().setCurrentState(state); + } + } + + @SuppressLint("InflateParams") + @SuppressWarnings({"methodref.receiver.bound.invalid", "nullness"}) + private PlaceListMapTemplatePresenter( + TemplateContext templateContext, + TemplateWrapper templateWrapper, + MapViewContainerFactory mapViewContainerFactory) { + super(templateContext, templateWrapper, StatusBarState.OVER_SURFACE); + + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext).inflate(R.layout.map_template_layout, null); + mCardContainer = mRootView.findViewById(R.id.card_container); + mHeaderView = mRootView.findViewById(R.id.header_view); + mContentView = mRootView.findViewById(R.id.content_view); + mActionStripView = mRootView.findViewById(R.id.action_strip); + mMapViewContainerFactory = mapViewContainerFactory; + // Note that the map container is instantiated during onStart. + + // We should always show an ItemList. + mCardContainer.setVisibility(View.VISIBLE); + + // Dynamically update the visible area inset. This allows the MapViewContainer to account + // for the insets when adjusting zoom levels to show all the place markers. + mGlobalLayoutListener = + () -> { + Rect safeAreaInset = new Rect(); + // The content container is always visible so just use its right. + safeAreaInset.left = mCardContainer.getRight(); + safeAreaInset.top = + mActionStripView.getVisibility() == VISIBLE + ? mActionStripView.getBottom() + : mRootView.getTop() + mRootView.getPaddingTop(); + safeAreaInset.bottom = mRootView.getBottom() - mRootView.getPaddingBottom(); + safeAreaInset.right = mRootView.getRight() - mRootView.getPaddingRight(); + templateContext.getSurfaceInfoProvider().setVisibleArea(safeAreaInset); + }; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/layout-w1280dp-h1000dp/map_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/layout-w1280dp-h1000dp/map_template_layout.xml new file mode 100644 index 0000000..03aac7d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/layout-w1280dp-h1000dp/map_template_layout.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- + The MapViewContainer is added programmatically here. + On configuration changes, the MapView is removed and re-added so that the + map can instantiate in the correct day/night mode. + TODO(b/159348229): update this once MapView has an explicit API to do this. + --> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <include + android:id="@+id/card_container" + android:layout_width="?templateCardContentContainerDefaultWidth" + android:layout_height="0dp" + android:layout_marginStart="?templateCardContentContainerStartMargin" + android:layout_marginTop="?templateCardContentContainerTopMargin" + app:layout_goneMarginTop="?templateCardContentContainerTopMargin" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/action_strip" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="0.0" + app:layout_constraintHeight_default="wrap" + app:layout_constraintHeight_min="?templateCardContentContainerMinHeight" + layout="@layout/card_container"/> + + <include + android:id="@+id/action_strip" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + layout="@layout/action_strip_view_floating"/> + </androidx.constraintlayout.widget.ConstraintLayout> +</FrameLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/layout/map_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/layout/map_template_layout.xml new file mode 100644 index 0000000..fb60c0f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/layout/map_template_layout.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- + The MapViewContainer is added programmatically here. + On configuration changes, the MapView is removed and re-added so that the + map can instantiate in the correct day/night mode. + TODO(b/159348229): update this once MapView has an explicit API to do this. + --> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <include + android:id="@+id/card_container" + layout="@layout/card_container"/> + + <include + android:id="@+id/action_strip" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + layout="@layout/action_strip_view_floating"/> + </androidx.constraintlayout.widget.ConstraintLayout> +</FrameLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/transition/map_template_transition.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/transition/map_template_transition.xml new file mode 100644 index 0000000..59e3103 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/maps/res/transition/map_template_transition.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<transitionSet xmlns:android="http://schemas.android.com/apk/res/android" + android:transitionOrdering="together"> + <targets> + <target android:excludeId="@id/map_container" /> + </targets> + <fade + android:fadingMode="fade_in_out" + android:duration="?templateUpdateAnimationDurationMilliseconds"/> + <changeBounds + android:duration="?templateUpdateAnimationDurationMilliseconds" + android:interpolator="@interpolator/fast_out_slow_in"/> +</transitionSet> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/NavigationTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/NavigationTemplatePresenter.java new file mode 100644 index 0000000..fe6aebc --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/NavigationTemplatePresenter.java @@ -0,0 +1,695 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.navigation; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static com.android.car.libraries.templates.host.view.widgets.common.ActionStripView.ACTIONSTRIP_ACTIVE_STATE_DURATION_MILLIS; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.annotation.SuppressLint; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.drawable.GradientDrawable; +import android.transition.TransitionInflater; +import android.transition.TransitionManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.ViewTreeObserver.OnGlobalFocusChangeListener; +import android.view.animation.Animation; +import android.view.animation.AnimationUtils; +import android.widget.FrameLayout; +import android.widget.ImageView; +import androidx.annotation.ColorInt; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.CarColor; +import androidx.car.app.model.CarText; +import androidx.car.app.model.TemplateWrapper; +import androidx.car.app.navigation.model.MessageInfo; +import androidx.car.app.navigation.model.NavigationTemplate; +import androidx.car.app.navigation.model.NavigationTemplate.NavigationInfo; +import androidx.car.app.navigation.model.PanModeDelegate; +import androidx.car.app.navigation.model.RoutingInfo; +import androidx.car.app.navigation.model.TravelEstimate; +import com.android.car.libraries.apphost.common.CarColorUtils; +import com.android.car.libraries.apphost.common.EventManager; +import com.android.car.libraries.apphost.common.EventManager.EventType; +import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.common.ThreadUtils; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +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.template.view.model.ActionStripWrapper; +import com.android.car.libraries.apphost.view.AbstractSurfaceTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.animation.AnimationListenerAdapter; +import com.android.car.libraries.templates.host.view.widgets.common.ActionStripView; +import com.android.car.libraries.templates.host.view.widgets.common.BleedingCardView; +import com.android.car.libraries.templates.host.view.widgets.common.PanOverlayView; +import com.android.car.libraries.templates.host.view.widgets.navigation.CompactStepView; +import com.android.car.libraries.templates.host.view.widgets.navigation.DetailedStepView; +import com.android.car.libraries.templates.host.view.widgets.navigation.MessageView; +import com.android.car.libraries.templates.host.view.widgets.navigation.ProgressView; +import com.android.car.libraries.templates.host.view.widgets.navigation.TravelEstimateView; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * A {@link TemplatePresenter} that shows various navigation cards such as routing cards, + * destination cards etc. + */ +public class NavigationTemplatePresenter extends AbstractSurfaceTemplatePresenter + implements ActionStripView.ActiveStateDelegate { + private static final int MAX_IMAGES_PER_TEXT_LINE = 2; + + /** Percentage to lower the brightness of the card's background. */ + private static final float CARD_BACKGROUND_DARKEN_PERCENTAGE = 0.2f; + + /** Percentage to lower the brightness of the compact step section of the card's background. */ + private static final float COMPACT_STEP_CARD_BACKGROUND_DARKEN_PERCENTAGE = 0.4f; + + /** The ratio between the junction image max height to the routing card. */ + private static final float JUNCTION_IMAGE_MAX_HEIGHT_TO_CARD_WIDTH_RATIO = 0.625f; + + /** The ratio between the lanes image container height to the routing card. */ + private static final float LANES_IMAGE_CONTAINER_HEIGHT_TO_CARD_WIDTH_RATIO = 0.175f; + + /** + * {@link #showActionStripViews()} is called in {@link #onStart()}, but a bug in GMS core causes + * {@link View#isInTouchMode()} in {@link #onStart()} to return {@code true} even in rotary or + * touchpad mode (b/128031459), which prevents the action strip from taking the input focus. We + * use this listener to call {@link #showActionStripViews()} after the touch mode changes to the + * correct value. + */ + private final OnGlobalFocusChangeListener mOnGlobalFocusChangeListener = + new OnGlobalFocusChangeListener() { + // call to showActionStripView() not allowed on the given receiver. + @SuppressWarnings("nullness:method.invocation") + @Override + public void onGlobalFocusChanged(View oldFocus, View newFocus) { + if (newFocus != null) { + showActionStripViews(); + } + } + }; + + @ColorInt private final int mNavCardFallbackContentColor; + + private int mStepsCardContainerVisibility = GONE; + private int mTravelEstimateContainerVisibility = GONE; + + private final ViewGroup mRootView; + private final BleedingCardView mStepsCardContainer; + private final ViewGroup mStepsContainer; + private final MessageView mMessageView; + private final ProgressView mProgressView; + private final ViewGroup mTravelEstimateContainer; + private final ImageView mJunctionImageView; + private final FrameLayout mJunctionImageContainer; + private final FrameLayout mLanesImageContainerView; + private final DetailedStepView mDetailedStepView; + private final CompactStepView mCompactStepView; + private final TravelEstimateView mTravelEstimateView; + private final ActionStripView mActionStripView; + private final ActionStripView mMapActionStripView; + private final PanOverlayView mPanOverlay; + private final CarTextParams mCurrentStepParams; + private final CarTextParams mNextStepParams; + @ColorInt private final int mDefaultCardBackgroundColor; + + /** Creates a {@link NavigationTemplatePresenter}. */ + public static NavigationTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + NavigationTemplatePresenter presenter = + new NavigationTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + public void onStart() { + super.onStart(); + + showActionStripViews(); + EventManager eventManager = getTemplateContext().getEventManager(); + eventManager.subscribeEvent( + this, EventType.TEMPLATE_TOUCHED_OR_FOCUSED, this::showActionStripViews); + eventManager.subscribeEvent(this, EventType.WINDOW_FOCUS_CHANGED, this::showActionStripViews); + eventManager.subscribeEvent( + this, + EventType.CONFIGURATION_CHANGED, + () -> { + NavigationTemplate template = (NavigationTemplate) getTemplate(); + updateActionStrip(template.getActionStrip()); + updateMapActionStrip(template.getMapActionStrip()); + wrapActionStripsIfNeeded(); + }); + getView().getViewTreeObserver().addOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener); + getTemplateContext().getRoutingInfoState().setIsRoutingInfoVisible(true); + } + + @Override + public void onStop() { + getTemplateContext().getRoutingInfoState().setIsRoutingInfoVisible(false); + EventManager eventManager = getTemplateContext().getEventManager(); + eventManager.unsubscribeEvent(this, EventType.TEMPLATE_TOUCHED_OR_FOCUSED); + eventManager.unsubscribeEvent(this, EventType.WINDOW_FOCUS_CHANGED); + eventManager.unsubscribeEvent(this, EventType.CONFIGURATION_CHANGED); + getView().getViewTreeObserver().removeOnGlobalFocusChangeListener(mOnGlobalFocusChangeListener); + + super.onStop(); + } + + @Override + public View getView() { + return mRootView; + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + public boolean isPanAndZoomEnabled() { + return getTemplateContext().getCarHostConfig().isNavPanZoomEnabled(); + } + + @Override + public void onPanModeChanged(boolean isInPanMode) { + showActionStripViews(); + updateVisibility(isInPanMode); + dispatchPanModeChange(isInPanMode); + } + + @Override + protected View getDefaultFocusedView() { + // Check the action strip visibility because the action buttons can take focus even when the + // action strip is gone. + if (mActionStripView.getVisibility() == VISIBLE) { + return mActionStripView; + } + if (mMapActionStripView.getVisibility() == VISIBLE) { + return mMapActionStripView; + } + return super.getDefaultFocusedView(); + } + + @Override + public void calculateAdditionalInset(Rect inset) { + // The portrait inset is more favorable to portrait screens it is calculated as the following + // bounding box: + // * left: inset left + // * right: Min(inset right, mapActionStrip left) + // * top: Max(inset top, actionStrip bottom, steps card bottom) + // * bottom: Min(inset bottom, travelEstimateContainer) + Rect portraitScreenInset = new Rect(inset); + + // The landscape inset is more favorable to landscape screens it is calculated as the following + // bounding box: + // * left: Max(inset left, travelEstimateContainer, stepsContainer) + // * right: Min(inset right, mapActionStrip left) + // * top: Max(inset top, actionStrip bottom) + // * bottom: inset bottom + Rect landscapeScreenInset = new Rect(inset); + + if (mMapActionStripView.getVisibility() == View.VISIBLE) { + landscapeScreenInset.right = min(landscapeScreenInset.right, mMapActionStripView.getLeft()); + portraitScreenInset.right = min(portraitScreenInset.right, mMapActionStripView.getLeft()); + } + if (mActionStripView.getVisibility() == VISIBLE) { + landscapeScreenInset.top = max(landscapeScreenInset.top, mActionStripView.getBottom()); + portraitScreenInset.top = max(portraitScreenInset.top, mActionStripView.getBottom()); + } + if (mTravelEstimateContainerVisibility == VISIBLE) { + portraitScreenInset.bottom = + min(portraitScreenInset.bottom, mTravelEstimateContainer.getTop()); + landscapeScreenInset.left = + max(landscapeScreenInset.left, mTravelEstimateContainer.getRight()); + } + if (mStepsCardContainerVisibility == View.VISIBLE) { + landscapeScreenInset.left = max(landscapeScreenInset.left, mStepsCardContainer.getRight()); + portraitScreenInset.top = max(portraitScreenInset.top, mStepsCardContainer.getBottom()); + } + int landscapeScreenArea = landscapeScreenInset.height() * landscapeScreenInset.width(); + int portraitScreenArea = portraitScreenInset.height() * portraitScreenInset.width(); + inset.set( + landscapeScreenArea > portraitScreenArea ? landscapeScreenInset : portraitScreenInset); + } + + @Override + public void onActiveStateVisibilityChanged() { + requestVisibleAreaUpdate(); + } + + private void update() { + NavigationTemplate template = (NavigationTemplate) getTemplate(); + updateActionStrip(template.getActionStrip()); + updateMapActionStrip(template.getMapActionStrip()); + getPanZoomManager().setEnabled(hasPanButton()); + setStepsCardBackgroundColor(); + setStepsCardContentColor(); + + TransitionManager.beginDelayedTransition( + mRootView, + TransitionInflater.from(getTemplateContext()) + .inflateTransition(R.transition.routing_card_transition)); + + @ColorInt int cardBackgroundColor = mStepsCardContainer.getCardBackgroundColor(); + boolean shouldHideTravelEstimate = false; + NavigationInfo navigationInfo = template.getNavigationInfo(); + if (navigationInfo == null) { + mStepsCardContainerVisibility = GONE; + mProgressView.setVisibility(GONE); + mStepsContainer.setVisibility(GONE); + mMessageView.setVisibility(GONE); + } else if (navigationInfo instanceof RoutingInfo) { + RoutingInfo routingInfo = (RoutingInfo) navigationInfo; + + if (routingInfo.isLoading()) { + mStepsCardContainerVisibility = VISIBLE; + mProgressView.setVisibility(VISIBLE); + mStepsContainer.setVisibility(GONE); + mMessageView.setVisibility(GONE); + } else { + + boolean shouldShowJunctionImage = + ImageUtils.setImageSrc( + getTemplateContext(), + routingInfo.getJunctionImage(), + mJunctionImageView, + ImageViewParams.DEFAULT); + + boolean shouldShowNextStep = routingInfo.getNextStep() != null; + + mDetailedStepView.setStepAndDistance( + getTemplateContext(), + routingInfo.getCurrentStep(), + routingInfo.getCurrentDistance(), + mCurrentStepParams, + cardBackgroundColor, + shouldShowJunctionImage); + mCompactStepView.setStep( + getTemplateContext(), routingInfo.getNextStep(), mNextStepParams, cardBackgroundColor); + + if (shouldShowJunctionImage) { + mJunctionImageContainer.setVisibility(VISIBLE); + mCompactStepView.setVisibility(GONE); + shouldHideTravelEstimate = true; + } else { + mJunctionImageContainer.setVisibility(GONE); + mCompactStepView.setVisibility(shouldShowNextStep ? VISIBLE : GONE); + } + + boolean hasNextStepOrJunction = shouldShowJunctionImage || shouldShowNextStep; + mStepsCardContainer + .findViewById(R.id.divider) + .setVisibility(hasNextStepOrJunction ? VISIBLE : GONE); + + mStepsCardContainerVisibility = VISIBLE; + mProgressView.setVisibility(GONE); + mStepsContainer.setVisibility(VISIBLE); + mMessageView.setVisibility(GONE); + } + } else if (navigationInfo instanceof MessageInfo) { + MessageInfo messageInfo = (MessageInfo) navigationInfo; + CarText title = messageInfo.getTitle(); + if (title == null) { + L.w(LogTags.TEMPLATE, "Title for the message is expected but not set"); + title = CarText.create(""); + } + mMessageView.setMessage( + getTemplateContext(), + messageInfo.getImage(), + title, + messageInfo.getText(), + cardBackgroundColor); + mStepsCardContainerVisibility = VISIBLE; + mProgressView.setVisibility(GONE); + mStepsContainer.setVisibility(GONE); + mMessageView.setVisibility(VISIBLE); + } else { + L.w(LogTags.TEMPLATE, "Unknown navigation info: %s", navigationInfo); + } + + TravelEstimate travelEstimate = template.getDestinationTravelEstimate(); + if (travelEstimate == null || shouldHideTravelEstimate) { + mTravelEstimateContainerVisibility = GONE; + } else { + mTravelEstimateView.setTravelEstimate(getTemplateContext(), travelEstimate); + mTravelEstimateContainerVisibility = VISIBLE; + } + + updateVisibility(getPanZoomManager().isInPanMode()); + + // Wrap action strips after the visibility update, because we need to know if the routing + // card is visible in order to decide whether the action strips need to be wrapped. + wrapActionStripsIfNeeded(); + + getTemplateContext().getSurfaceInfoProvider().invalidateStableArea(); + requestVisibleAreaUpdate(); + } + + /** + * Navigation template allows up to 4 buttons, which may overlap with the routing card container + * in small screens. In this case, draw the buttons in 2 lines to avoid the overlap. + */ + // TODO(b/191828230): Determine the action strip overlaps properly + private void wrapActionStripsIfNeeded() { + ThreadUtils.runOnMain( + () -> { + NavigationTemplate template = (NavigationTemplate) getTemplate(); + int screenWidth = getTemplateContext().getResources().getDisplayMetrics().widthPixels; + int screenHeight = getTemplateContext().getResources().getDisplayMetrics().heightPixels; + + // Measure and layout manually to get the correct view widths. + mRootView.measure( + MeasureSpec.makeMeasureSpec(screenWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(screenHeight, MeasureSpec.EXACTLY)); + mRootView.layout(0, 0, screenWidth, screenHeight); + + // We calculate the right side of the card container and the left side of the + // action strip because the manual measure and layout calls do not produce the + // correct view position in the window. + MarginLayoutParams stepsCardContainerLayoutParams = + (MarginLayoutParams) mStepsCardContainer.getLayoutParams(); + int stepsCardContainerRight = + stepsCardContainerLayoutParams.getMarginStart() + + stepsCardContainerLayoutParams.width; + int actionStripViewLeft = screenWidth - mActionStripView.getWidth(); + + // If the card container and the action strip view overlap, draw the action + // strip in 2 lines to avoid the overlap. + if (mStepsCardContainer.getVisibility() == VISIBLE + && mActionStripView.getVisibility() == VISIBLE + && stepsCardContainerRight > actionStripViewLeft) { + updateActionStrip(template.getActionStrip(), /* allowTwoLines= */ true); + } + + // We calculate the bottom side of the action strip and the top side of the map + // action strip because the manual measure and layout calls do not produce the + // correct view position in the window. + int actionStripViewBottom = mActionStripView.getBottom(); + int mapActionStripViewTop = mMapActionStripView.getTop(); + + // If the action strip and the map action strip views overlap, draw the map + // action strip in 2 lines to avoid the overlap. + if (mActionStripView.getVisibility() == VISIBLE + && mMapActionStripView.getVisibility() == VISIBLE + && actionStripViewBottom > mapActionStripViewTop) { + updateMapActionStrip(template.getMapActionStrip(), /* allowTwoLines= */ true); + } + }); + } + + private void updateActionStrip(@Nullable ActionStrip actionStrip) { + updateActionStrip(actionStrip, /* allowTwoLines= */ false); + } + + private void updateActionStrip(@Nullable ActionStrip actionStrip, boolean allowTwoLines) { + mActionStripView.setActionStrip( + getTemplateContext(), + actionStrip, + ActionsConstraints.ACTIONS_CONSTRAINTS_NAVIGATION, + allowTwoLines); + } + + private void updateMapActionStrip(@Nullable ActionStrip actionStrip) { + updateMapActionStrip(actionStrip, /* allowTwoLines= */ false); + } + + private void updateMapActionStrip(@Nullable ActionStrip actionStrip, boolean allowTwoLines) { + ActionStripWrapper actionStripWrapper = null; + if (actionStrip != null) { + actionStripWrapper = + getPanZoomManager() + .getMapActionStripWrapper( + /* templateContext= */ getTemplateContext(), /* actionStrip= */ actionStrip); + } + + mMapActionStripView.setActionStrip( + getTemplateContext(), + actionStripWrapper, + ActionsConstraints.ACTIONS_CONSTRAINTS_NAVIGATION_MAP, + allowTwoLines); + } + + private void showActionStripViews() { + boolean isInPanMode = getPanZoomManager().isInPanMode(); + + // Show the action strip when not in the pan mode. + mActionStripView.setActiveState(!isInPanMode); + mMapActionStripView.setActiveState(true); + + // If nothing was focused, set the default focus. + if (!mRootView.hasFocus()) { + setDefaultFocus(); + } + + // The action strip view should fade if the action strip or the window is not focused. + if (!(mActionStripView.hasFocus() || mMapActionStripView.hasFocus()) || !hasWindowFocus()) { + mActionStripView.setActiveStateWithDelay(false, ACTIONSTRIP_ACTIVE_STATE_DURATION_MILLIS); + + // Fade the map action strip only when not in the pan mode. + if (!isInPanMode) { + mMapActionStripView.setActiveStateWithDelay( + false, ACTIONSTRIP_ACTIVE_STATE_DURATION_MILLIS); + } + } + } + + private void attachActiveStateDelegate() { + mActionStripView.setActiveStateDelegate(this); + mMapActionStripView.setActiveStateDelegate(this); + } + + @SuppressLint("InflateParams") + @SuppressWarnings("nullness:method.invocation") + private NavigationTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.OVER_SURFACE); + + // Read the fallback color to use with the app-defined card background color. + @StyleableRes final int[] themeAttrs = {R.attr.templateNavCardFallbackContentColor}; + TypedArray ta = templateContext.obtainStyledAttributes(themeAttrs); + mNavCardFallbackContentColor = ta.getColor(0, Color.WHITE); + ta.recycle(); + + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext).inflate(R.layout.navigation_template_layout, null); + mStepsCardContainer = mRootView.findViewById(R.id.content_container); + mMessageView = mRootView.findViewById(R.id.message_view); + mProgressView = mRootView.findViewById(R.id.progress_view); + mStepsContainer = mRootView.findViewById(R.id.steps_container); + + mJunctionImageContainer = mRootView.findViewById(R.id.junction_image_container); + mJunctionImageView = mRootView.findViewById(R.id.junction_image); + mLanesImageContainerView = mRootView.findViewById(R.id.lanes_image_container); + mDetailedStepView = mRootView.findViewById(R.id.detailed_step_view); + mCompactStepView = mRootView.findViewById(R.id.compact_step_view); + mTravelEstimateContainer = mRootView.findViewById(R.id.travel_estimate_card_container); + mTravelEstimateView = mRootView.findViewById(R.id.travel_estimate_view); + mActionStripView = mRootView.findViewById(R.id.action_strip); + mMapActionStripView = mRootView.findViewById(R.id.map_action_strip); + mPanOverlay = mRootView.findViewById(R.id.pan_overlay); + + mCurrentStepParams = createStepTextParams(/* isNextStep= */ false); + mNextStepParams = createStepTextParams(/* isNextStep= */ true); + mDefaultCardBackgroundColor = mStepsCardContainer.getCardBackgroundColor(); + + setStepsCardBackgroundColor(); + + // Set the junction image max height and lanes image container height. + setJunctionImageMaxHeight(); + setLanesImageContainerHeight(); + + attachActiveStateDelegate(); + } + + /** + * Returns a {@link CarTextParams} instance to use for the text of a step. + * + * <p>Unlike other text elsewhere, image spans are allowed in these strings. + */ + @SuppressLint("ResourceType") + private CarTextParams createStepTextParams(boolean isNextStep) { + TemplateContext templateContext = getTemplateContext(); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateRoutingImageSpanRatio, + R.attr.templateRoutingImageSpanBody2MaxHeight, + R.attr.templateRoutingImageSpanBody3MaxHeight, + }; + TypedArray ta = templateContext.obtainStyledAttributes(themeAttrs); + float imageRatio = ta.getFloat(0, 0.f); + int body2MaxHeight = ta.getDimensionPixelSize(1, 0); + int body3MaxHeight = ta.getDimensionPixelSize(2, 0); + ta.recycle(); + + int maxHeight = isNextStep ? body3MaxHeight : body2MaxHeight; + int maxWidth = (int) (maxHeight * imageRatio); + return CarTextParams.builder() + .setImageBoundingBox(new Rect(0, 0, maxWidth, maxHeight)) + .setMaxImages(MAX_IMAGES_PER_TEXT_LINE) + .setColorSpanConstraints(CarColorConstraints.NO_COLOR) + .build(); + } + + private void showTravelEstimateContainer() { + if (mTravelEstimateContainer.getVisibility() == VISIBLE) { + return; + } + + mTravelEstimateContainer.setVisibility(VISIBLE); + Animation animation = + AnimationUtils.loadAnimation( + getTemplateContext(), R.anim.travel_estimate_card_show_animation); + mTravelEstimateContainer.setAnimation(animation); + } + + private void hideTravelEstimateContainer() { + if (mTravelEstimateContainer.getVisibility() == GONE) { + return; + } + Animation animation = + AnimationUtils.loadAnimation( + getTemplateContext(), R.anim.travel_estimate_card_hide_animation); + // TODO(b/180455232): Create default AnimationListenerListener with empty methods. + animation.setAnimationListener( + new AnimationListenerAdapter() { + @Override + public void onAnimationEnd(Animation animation) { + mTravelEstimateContainer.setVisibility(GONE); + } + }); + mTravelEstimateContainer.setAnimation(animation); + } + + private void setStepsCardBackgroundColor() { + // Set the card's background color to the one provided in the template, if any. + CarColor backgroundColor = ((NavigationTemplate) getTemplate()).getBackgroundColor(); + @ColorInt int backgroundColorInt; + if (backgroundColor != null) { + backgroundColorInt = + CarColorUtils.resolveColor( + getTemplateContext(), + backgroundColor, + false, + Color.BLACK, + CarColorConstraints.UNCONSTRAINED); + } else { + backgroundColorInt = mDefaultCardBackgroundColor; + } + + // Darken the background of the card. + mStepsCardContainer.setCardBackgroundColor( + CarColorUtils.darkenColor(backgroundColorInt, CARD_BACKGROUND_DARKEN_PERCENTAGE)); + + // Darken the background of the compat step view. + // We also create a drawable for it that has bottom rounded corners because otherwise the + // background of the card won't clip within the parent's outline. It is probably possible to + // do the clipping using a convex path (getting the card's background outline and using that + // does not work as it returns a rect and not a path), and setting it through an outline + // provider but this is cheaper regardless as clipping is an expensive operation. + float bottomRadius = mStepsCardContainer.getCardRadius(); + GradientDrawable drawable = new GradientDrawable(); + drawable.setCornerRadii( + new float[] {0, 0, 0, 0, bottomRadius, bottomRadius, bottomRadius, bottomRadius}); + drawable.setColor( + CarColorUtils.darkenColor( + backgroundColorInt, COMPACT_STEP_CARD_BACKGROUND_DARKEN_PERCENTAGE)); + mCompactStepView.setBackground(drawable); + } + + private void setStepsCardContentColor() { + if (((NavigationTemplate) getTemplate()).getBackgroundColor() != null) { + // Use the fallback content color if the app-defined card background color is used, + // because the OEM-defined text color may not have the adequate contrast ratio with the + // card background color. + mDetailedStepView.setTextColor(mNavCardFallbackContentColor); + mCompactStepView.setTextColor(mNavCardFallbackContentColor); + mMessageView.setTextColor(mNavCardFallbackContentColor); + mProgressView.setColor(mNavCardFallbackContentColor); + } else { + mDetailedStepView.setDefaultTextColor(); + mCompactStepView.setDefaultTextColor(); + mMessageView.setDefaultTextColor(); + mProgressView.setDefaultColor(); + } + } + + private void setJunctionImageMaxHeight() { + int stepsCardContainerWidth = mStepsCardContainer.getLayoutParams().width; + int junctionImageMaxHeight = + (int) (stepsCardContainerWidth * JUNCTION_IMAGE_MAX_HEIGHT_TO_CARD_WIDTH_RATIO); + mJunctionImageView.setMaxHeight(junctionImageMaxHeight); + } + + private void setLanesImageContainerHeight() { + int stepsCardContainerWidth = mStepsCardContainer.getLayoutParams().width; + int lanesImageContainerHeight = + (int) (stepsCardContainerWidth * LANES_IMAGE_CONTAINER_HEIGHT_TO_CARD_WIDTH_RATIO); + mLanesImageContainerView.getLayoutParams().height = lanesImageContainerHeight; + } + + private boolean hasPanButton() { + NavigationTemplate template = (NavigationTemplate) getTemplate(); + ActionStrip mapActionStrip = template.getMapActionStrip(); + return mapActionStrip != null && mapActionStrip.getFirstActionOfType(Action.TYPE_PAN) != null; + } + + private void dispatchPanModeChange(boolean isInPanMode) { + NavigationTemplate template = (NavigationTemplate) getTemplate(); + PanModeDelegate panModeDelegate = template.getPanModeDelegate(); + if (panModeDelegate != null) { + getTemplateContext().getAppDispatcher().dispatchPanModeChanged(panModeDelegate, isInPanMode); + } + } + + private void updateVisibility(boolean isInPanMode) { + ThreadUtils.runOnMain( + () -> { + if (isInPanMode) { + mPanOverlay.setVisibility(VISIBLE); + mStepsCardContainer.setVisibility(GONE); + mActionStripView.setActiveState(false); + hideTravelEstimateContainer(); + } else { + mPanOverlay.setVisibility(GONE); + mStepsCardContainer.setVisibility(mStepsCardContainerVisibility); + if (mTravelEstimateContainerVisibility == VISIBLE) { + showTravelEstimateContainer(); + } else { + hideTravelEstimateContainer(); + } + } + }); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/NavigationTemplatePresenterFactory.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/NavigationTemplatePresenterFactory.java new file mode 100644 index 0000000..7fd88e1 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/NavigationTemplatePresenterFactory.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.templates.host.view.presenters.navigation; + +import androidx.car.app.model.Template; +import androidx.car.app.model.TemplateWrapper; +import androidx.car.app.navigation.model.NavigationTemplate; +import androidx.car.app.navigation.model.PlaceListNavigationTemplate; +import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate; +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.view.TemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenterFactory; +import com.google.common.collect.ImmutableSet; +import java.util.Collection; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Implementation of a {@link TemplatePresenterFactory} for the production host, responsible for + * providing {@link TemplatePresenter} instances for the set of templates the host supports. + */ +public class NavigationTemplatePresenterFactory implements TemplatePresenterFactory { + private static final NavigationTemplatePresenterFactory sInstance = + new NavigationTemplatePresenterFactory(); + private static final ImmutableSet<Class<? extends Template>> SUPPORTED_TEMPLATES = + ImmutableSet.of( + NavigationTemplate.class, + PlaceListNavigationTemplate.class, + RoutePreviewNavigationTemplate.class); + + /** Gets the singleton instance of{@link NavigationTemplatePresenterFactory}. */ + public static NavigationTemplatePresenterFactory get() { + return sInstance; + } + + @Override + @Nullable + public TemplatePresenter createPresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + Template template = templateWrapper.getTemplate(); + + Class<? extends Template> clazz = template.getClass(); + if (NavigationTemplate.class == clazz) { + return NavigationTemplatePresenter.create(templateContext, templateWrapper); + } else if (PlaceListNavigationTemplate.class == clazz) { + return PlaceListNavigationTemplatePresenter.create(templateContext, templateWrapper); + } else if (RoutePreviewNavigationTemplate.class == clazz) { + return RoutePreviewNavigationTemplatePresenter.create(templateContext, templateWrapper); + } else { + L.w( + LogTags.TEMPLATE, + "Don't know how to create a presenter for template: %s", + clazz.getSimpleName()); + } + return null; + } + + @Override + public Collection<Class<? extends Template>> getSupportedTemplates() { + return SUPPORTED_TEMPLATES; + } + + private NavigationTemplatePresenterFactory() {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/PlaceListNavigationTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/PlaceListNavigationTemplatePresenter.java new file mode 100644 index 0000000..0f25c8c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/PlaceListNavigationTemplatePresenter.java @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.navigation; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static com.android.car.libraries.apphost.distraction.constraints.RowListConstraints.ROW_LIST_CONSTRAINTS_SIMPLE; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.annotation.SuppressLint; +import android.graphics.Rect; +import android.transition.TransitionInflater; +import android.transition.TransitionManager; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.TemplateWrapper; +import androidx.car.app.navigation.model.PanModeDelegate; +import androidx.car.app.navigation.model.PlaceListNavigationTemplate; +import com.android.car.libraries.apphost.common.EventManager.EventType; +import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.common.ThreadUtils; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.template.view.model.ActionStripWrapper; +import com.android.car.libraries.apphost.template.view.model.RowListWrapper; +import com.android.car.libraries.apphost.template.view.model.RowWrapper; +import com.android.car.libraries.apphost.view.AbstractSurfaceTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ActionStripView; +import com.android.car.libraries.templates.host.view.widgets.common.CardHeaderView; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.android.car.libraries.templates.host.view.widgets.common.PanOverlayView; +import com.google.common.collect.ImmutableList; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** A {@link TemplatePresenter} that shows a {@link PlaceListNavigationTemplate}. */ +public class PlaceListNavigationTemplatePresenter extends AbstractSurfaceTemplatePresenter { + private final ViewGroup mRootView; + private final ViewGroup mContentContainer; + private final CardHeaderView mHeaderView; + private final ContentView mContentView; + private final ActionStripView mActionStripView; + private final ActionStripView mMapActionStripView; + private final PanOverlayView mPanOverlay; + + /** Creates a {@link PlaceListNavigationTemplatePresenter}. */ + public static PlaceListNavigationTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + PlaceListNavigationTemplatePresenter presenter = + new PlaceListNavigationTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + public View getView() { + return mRootView; + } + + @Override + public void onStart() { + super.onStart(); + + getTemplateContext() + .getEventManager() + .subscribeEvent( + this, + EventType.CONFIGURATION_CHANGED, + () -> { + PlaceListNavigationTemplate template = (PlaceListNavigationTemplate) getTemplate(); + updateActionStrip(template.getActionStrip()); + updateMapActionStrip(template.getActionStrip()); + }); + } + + @Override + public void onStop() { + getTemplateContext().getEventManager().unsubscribeEvent(this, EventType.CONFIGURATION_CHANGED); + + super.onStop(); + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + public boolean isPanAndZoomEnabled() { + return getTemplateContext().getCarHostConfig().isPoiRoutePreviewPanZoomEnabled(); + } + + @Override + public void onPanModeChanged(boolean isInPanMode) { + updateVisibility(isInPanMode); + dispatchPanModeChange(isInPanMode); + } + + @Override + protected View getDefaultFocusedView() { + if (mContentView.getVisibility() == VISIBLE) { + return mContentView; + } + return super.getDefaultFocusedView(); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent keyEvent) { + if (getPanZoomManager().handlePanEventsIfNeeded(keyCode)) { + return true; + } + + // Move between the card view and the action button view on left or right rotary nudge. + if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { + // If the focus is in the card view (back button or row list), request focus in the + // action + // strip. + if (moveFocusIfPresent( + ImmutableList.of(mContentContainer), ImmutableList.of(mActionStripView))) { + return true; + } + } else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { + // Request focus on the content view so that the first row in the list will take focus. + if (moveFocusIfPresent(ImmutableList.of(mActionStripView), ImmutableList.of(mContentView))) { + return true; + } + } + return super.onKeyUp(keyCode, keyEvent); + } + + @Override + public void calculateAdditionalInset(Rect inset) { + // The portrait inset is more favorable to portrait screens and is calculated as the following + // bounding box: + // * left: inset left + // * right: inset right + // * top: Max(inset top, actionStrip bottom, content view bottom) + // * bottom: inset bottom + Rect portraitScreenInset = new Rect(inset); + + // The landscape inset is more favorable to landscape screens it is calculated as the following + // bounding box: + // * left: Max(inset left, content view right) + // * right: inset right + // * top: Max(inset top, actionStrip bottom) + // * bottom: inset bottom + Rect landscapeScreenInset = new Rect(inset); + + if (mMapActionStripView.getVisibility() == VISIBLE) { + landscapeScreenInset.right = min(landscapeScreenInset.right, mMapActionStripView.getLeft()); + portraitScreenInset.right = min(portraitScreenInset.right, mMapActionStripView.getLeft()); + } + if (mActionStripView.getVisibility() == VISIBLE) { + landscapeScreenInset.top = max(landscapeScreenInset.top, mActionStripView.getBottom()); + portraitScreenInset.top = max(portraitScreenInset.top, mActionStripView.getBottom()); + } + if (mContentContainer.getVisibility() == View.VISIBLE) { + landscapeScreenInset.left = max(landscapeScreenInset.left, mContentContainer.getRight()); + portraitScreenInset.top = max(portraitScreenInset.top, mContentContainer.getBottom()); + } + int landscapeScreenArea = landscapeScreenInset.height() * landscapeScreenInset.width(); + int portraitScreenArea = portraitScreenInset.height() * portraitScreenInset.width(); + inset.set( + landscapeScreenArea > portraitScreenArea ? landscapeScreenInset : portraitScreenInset); + } + + @Override + public boolean handlesTemplateChangeAnimation() { + return true; + } + + private void update() { + TransitionManager.beginDelayedTransition( + mRootView, + TransitionInflater.from(getTemplateContext()) + .inflateTransition(R.transition.place_list_nav_template_transition)); + + PlaceListNavigationTemplate template = (PlaceListNavigationTemplate) getTemplate(); + + updateActionStrip(template.getActionStrip()); + updateMapActionStrip(template.getMapActionStrip()); + getPanZoomManager().setEnabled(hasPanButton()); + mHeaderView.setContent( + getTemplateContext(), + template.getTitle(), + template.getHeaderAction(), + template.getOnContentRefreshDelegate()); + + ItemList itemList = template.getItemList(); + mContentView.setRowListContent( + getTemplateContext(), + RowListWrapper.wrap(getTemplateContext(), itemList) + .setIsLoading(template.isLoading()) + .setRowFlags(RowWrapper.DEFAULT_UNIFORM_LIST_ROW_FLAGS) + .setRowListConstraints(ROW_LIST_CONSTRAINTS_SIMPLE) + .setIsRefresh(getTemplateWrapper().isRefresh()) + .setIsHalfList(true) + .build()); + + updateVisibility(getPanZoomManager().isInPanMode()); + + getTemplateContext().getSurfaceInfoProvider().invalidateStableArea(); + requestVisibleAreaUpdate(); + } + + private void updateActionStrip(@Nullable ActionStrip actionStrip) { + mActionStripView.setActionStrip( + getTemplateContext(), actionStrip, ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + } + + private void updateMapActionStrip(@Nullable ActionStrip actionStrip) { + ActionStripWrapper actionStripWrapper = null; + if (actionStrip != null) { + actionStripWrapper = + getPanZoomManager() + .getMapActionStripWrapper( + /* templateContext= */ getTemplateContext(), /* actionStrip= */ actionStrip); + } + + mMapActionStripView.setActionStrip( + getTemplateContext(), + actionStripWrapper, + ActionsConstraints.ACTIONS_CONSTRAINTS_NAVIGATION_MAP, + /* allowTwoLines= */ false); + } + + @SuppressLint("InflateParams") + @SuppressWarnings({"methodref.receiver.bound.invalid"}) + private PlaceListNavigationTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.OVER_SURFACE); + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext) + .inflate(R.layout.list_navigation_template_layout, null); + mContentContainer = mRootView.findViewById(R.id.content_container); + mHeaderView = mRootView.findViewById(R.id.header_view); + mContentView = mRootView.findViewById(R.id.content_view); + mActionStripView = mRootView.findViewById(R.id.action_strip); + mMapActionStripView = mRootView.findViewById(R.id.map_action_strip); + mPanOverlay = mRootView.findViewById(R.id.pan_overlay); + + // We should always show an ItemList. + mContentContainer.setVisibility(View.VISIBLE); + } + + private boolean hasPanButton() { + PlaceListNavigationTemplate template = (PlaceListNavigationTemplate) getTemplate(); + ActionStrip mapActionStrip = template.getMapActionStrip(); + return mapActionStrip != null && mapActionStrip.getFirstActionOfType(Action.TYPE_PAN) != null; + } + + private void dispatchPanModeChange(boolean isInPanMode) { + PlaceListNavigationTemplate template = (PlaceListNavigationTemplate) getTemplate(); + PanModeDelegate panModeDelegate = template.getPanModeDelegate(); + if (panModeDelegate != null) { + getTemplateContext().getAppDispatcher().dispatchPanModeChanged(panModeDelegate, isInPanMode); + } + } + + private void updateVisibility(boolean isInPanMode) { + ThreadUtils.runOnMain( + () -> { + if (isInPanMode) { + mPanOverlay.setVisibility(VISIBLE); + mContentContainer.setVisibility(GONE); + mActionStripView.setVisibility(GONE); + } else { + mPanOverlay.setVisibility(GONE); + mContentContainer.setVisibility(VISIBLE); + mActionStripView.setVisibility(VISIBLE); + } + }); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/RoutePreviewNavigationTemplatePresenter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/RoutePreviewNavigationTemplatePresenter.java new file mode 100644 index 0000000..d7cf9d1 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/RoutePreviewNavigationTemplatePresenter.java @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.presenters.navigation; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static com.android.car.libraries.apphost.distraction.constraints.RowListConstraints.ROW_LIST_CONSTRAINTS_ROUTE_PREVIEW; +import static com.android.car.libraries.apphost.template.view.model.RowListWrapper.LIST_FLAGS_SELECTABLE_FOCUS_SELECT_ROW; +import static com.android.car.libraries.apphost.template.view.model.RowListWrapper.LIST_FLAGS_SELECTABLE_HIGHLIGHT_ROW; +import static com.android.car.libraries.apphost.template.view.model.RowListWrapper.LIST_FLAGS_SELECTABLE_SCROLL_TO_ROW; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.annotation.SuppressLint; +import android.graphics.Rect; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.OnClickDelegate; +import androidx.car.app.model.TemplateWrapper; +import androidx.car.app.navigation.model.PanModeDelegate; +import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate; +import com.android.car.libraries.apphost.common.EventManager.EventType; +import com.android.car.libraries.apphost.common.StatusBarManager.StatusBarState; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.common.ThreadUtils; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.template.view.model.ActionStripWrapper; +import com.android.car.libraries.apphost.template.view.model.RowListWrapper; +import com.android.car.libraries.apphost.template.view.model.RowWrapper; +import com.android.car.libraries.apphost.view.AbstractSurfaceTemplatePresenter; +import com.android.car.libraries.apphost.view.TemplatePresenter; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ActionStripView; +import com.android.car.libraries.templates.host.view.widgets.common.CardHeaderView; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.android.car.libraries.templates.host.view.widgets.common.PanOverlayView; +import com.google.common.collect.ImmutableList; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** A {@link TemplatePresenter} that shows a {@link RoutePreviewNavigationTemplate}. */ +public class RoutePreviewNavigationTemplatePresenter extends AbstractSurfaceTemplatePresenter { + private final ViewGroup mRootView; + private final ViewGroup mContentContainer; + private final CardHeaderView mHeaderView; + private final ContentView mContentView; + private final ActionStripView mActionStripView; + private final ActionStripView mMapActionStripView; + private final PanOverlayView mPanOverlay; + + /** Creates a {@link RoutePreviewNavigationTemplatePresenter}. */ + public static RoutePreviewNavigationTemplatePresenter create( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + RoutePreviewNavigationTemplatePresenter presenter = + new RoutePreviewNavigationTemplatePresenter(templateContext, templateWrapper); + presenter.update(); + return presenter; + } + + @Override + public View getView() { + return mRootView; + } + + @Override + public void onStart() { + super.onStart(); + + getTemplateContext() + .getEventManager() + .subscribeEvent( + this, + EventType.CONFIGURATION_CHANGED, + () -> { + RoutePreviewNavigationTemplate template = + (RoutePreviewNavigationTemplate) getTemplate(); + updateActionStrip(template.getActionStrip()); + updateMapActionStrip(template.getMapActionStrip()); + }); + } + + @Override + public void onStop() { + getTemplateContext().getEventManager().unsubscribeEvent(this, EventType.CONFIGURATION_CHANGED); + + super.onStop(); + } + + @Override + public void onTemplateChanged() { + update(); + } + + @Override + public boolean isPanAndZoomEnabled() { + return getTemplateContext().getCarHostConfig().isPoiRoutePreviewPanZoomEnabled(); + } + + @Override + public void onPanModeChanged(boolean isInPanMode) { + updateVisibility(isInPanMode); + dispatchPanModeChange(isInPanMode); + } + + @Override + protected View getDefaultFocusedView() { + if (mContentView.getVisibility() == VISIBLE) { + return mContentView; + } + return super.getDefaultFocusedView(); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent keyEvent) { + if (getPanZoomManager().handlePanEventsIfNeeded(keyCode)) { + return true; + } + + // Move between the card view and the action button view on left or right rotary nudge. + if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { + // If the focus is in the card view (back button or row list), request focus in the + // action + // strip. + if (moveFocusIfPresent( + ImmutableList.of(mContentContainer), ImmutableList.of(mActionStripView))) { + return true; + } + } else if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { + // Request focus on the content view so that the first row in the list will take focus. + if (moveFocusIfPresent(ImmutableList.of(mActionStripView), ImmutableList.of(mContentView))) { + return true; + } + } + return super.onKeyUp(keyCode, keyEvent); + } + + public void calculateAdditionalInset(Rect inset) { + // The portrait inset is more favorable to portrait screens and is calculated as the following + // bounding box: + // * left: inset left + // * right: inset right + // * top: Max(inset top, actionStrip bottom, content view bottom) + // * bottom: inset bottom + Rect portraitScreenInset = new Rect(inset); + + // The landscape inset is more favorable to landscape screens it is calculated as the following + // bounding box: + // * left: Max(inset left, content view right) + // * right: inset right + // * top: Max(inset top, actionStrip bottom) + // * bottom: inset bottom + Rect landscapeScreenInset = new Rect(inset); + + if (mMapActionStripView.getVisibility() == VISIBLE) { + landscapeScreenInset.right = min(landscapeScreenInset.right, mMapActionStripView.getLeft()); + portraitScreenInset.right = min(portraitScreenInset.right, mMapActionStripView.getLeft()); + } + if (mActionStripView.getVisibility() == VISIBLE) { + landscapeScreenInset.top = max(landscapeScreenInset.top, mActionStripView.getBottom()); + portraitScreenInset.top = max(portraitScreenInset.top, mActionStripView.getBottom()); + } + if (mContentContainer.getVisibility() == View.VISIBLE) { + landscapeScreenInset.left = max(landscapeScreenInset.left, mContentContainer.getRight()); + portraitScreenInset.top = max(portraitScreenInset.top, mContentContainer.getBottom()); + } + int landscapeScreenArea = landscapeScreenInset.height() * landscapeScreenInset.width(); + int portraitScreenArea = portraitScreenInset.height() * portraitScreenInset.width(); + inset.set( + landscapeScreenArea > portraitScreenArea ? landscapeScreenInset : portraitScreenInset); + } + + private void update() { + RoutePreviewNavigationTemplate template = (RoutePreviewNavigationTemplate) getTemplate(); + + updateActionStrip(template.getActionStrip()); + updateMapActionStrip(template.getMapActionStrip()); + getPanZoomManager().setEnabled(hasPanButton()); + mHeaderView.setContent(getTemplateContext(), template.getTitle(), template.getHeaderAction()); + + ItemList itemList = template.getItemList(); + Action navigateAction = template.getNavigateAction(); + + RowListWrapper.Builder builder = + RowListWrapper.wrap(getTemplateContext(), itemList) + .setIsLoading(template.isLoading()) + .setRowFlags(RowWrapper.DEFAULT_UNIFORM_LIST_ROW_FLAGS) + .setRowListConstraints(ROW_LIST_CONSTRAINTS_ROUTE_PREVIEW) + .setIsRefresh(getTemplateWrapper().isRefresh()) + .setIsHalfList(true) + + // For the route preview list, don't use radio buttons but rather show the + // selection + // by changing the row background. + .setListFlags( + LIST_FLAGS_SELECTABLE_HIGHLIGHT_ROW + | LIST_FLAGS_SELECTABLE_FOCUS_SELECT_ROW + | LIST_FLAGS_SELECTABLE_SCROLL_TO_ROW); + if (!template.isLoading() && navigateAction != null) { + builder.setRowSelectedText(navigateAction.getTitle()); + OnClickDelegate onClickDelegate = navigateAction.getOnClickDelegate(); + if (onClickDelegate != null) { + TemplateContext templateContext = getTemplateContext(); + builder.setOnRepeatedSelectionCallback( + () -> templateContext.getAppDispatcher().dispatchClick(onClickDelegate)); + } + } + mContentView.setRowListContent(getTemplateContext(), builder.build()); + + updateVisibility(getPanZoomManager().isInPanMode()); + + getTemplateContext().getSurfaceInfoProvider().invalidateStableArea(); + requestVisibleAreaUpdate(); + } + + private void updateActionStrip(@Nullable ActionStrip actionStrip) { + mActionStripView.setActionStrip( + getTemplateContext(), actionStrip, ActionsConstraints.ACTIONS_CONSTRAINTS_SIMPLE); + } + + private void updateMapActionStrip(@Nullable ActionStrip actionStrip) { + ActionStripWrapper actionStripWrapper = null; + if (actionStrip != null) { + actionStripWrapper = + getPanZoomManager() + .getMapActionStripWrapper( + /* templateContext= */ getTemplateContext(), /* actionStrip= */ actionStrip); + } + + mMapActionStripView.setActionStrip( + getTemplateContext(), + actionStripWrapper, + ActionsConstraints.ACTIONS_CONSTRAINTS_NAVIGATION_MAP, + /* allowTwoLines= */ false); + } + + @SuppressLint("InflateParams") + @SuppressWarnings({"methodref.receiver.bound.invalid"}) + private RoutePreviewNavigationTemplatePresenter( + TemplateContext templateContext, TemplateWrapper templateWrapper) { + super(templateContext, templateWrapper, StatusBarState.OVER_SURFACE); + + mRootView = + (ViewGroup) + LayoutInflater.from(templateContext) + .inflate(R.layout.list_navigation_template_layout, null); + mContentContainer = mRootView.findViewById(R.id.content_container); + mHeaderView = mRootView.findViewById(R.id.header_view); + mContentView = mRootView.findViewById(R.id.content_view); + mActionStripView = mRootView.findViewById(R.id.action_strip); + mMapActionStripView = mRootView.findViewById(R.id.map_action_strip); + mPanOverlay = mRootView.findViewById(R.id.pan_overlay); + + mContentContainer.setVisibility(View.VISIBLE); + } + + private boolean hasPanButton() { + RoutePreviewNavigationTemplate template = (RoutePreviewNavigationTemplate) getTemplate(); + ActionStrip mapActionStrip = template.getMapActionStrip(); + return mapActionStrip != null && mapActionStrip.getFirstActionOfType(Action.TYPE_PAN) != null; + } + + private void dispatchPanModeChange(boolean isInPanMode) { + RoutePreviewNavigationTemplate template = (RoutePreviewNavigationTemplate) getTemplate(); + PanModeDelegate panModeDelegate = template.getPanModeDelegate(); + if (panModeDelegate != null) { + getTemplateContext().getAppDispatcher().dispatchPanModeChanged(panModeDelegate, isInPanMode); + } + } + + private void updateVisibility(boolean isInPanMode) { + ThreadUtils.runOnMain( + () -> { + if (isInPanMode) { + mPanOverlay.setVisibility(VISIBLE); + mContentContainer.setVisibility(GONE); + mActionStripView.setVisibility(GONE); + } else { + mPanOverlay.setVisibility(GONE); + mContentContainer.setVisibility(VISIBLE); + mActionStripView.setVisibility(VISIBLE); + } + }); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/anim/travel_estimate_card_hide_animation.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/anim/travel_estimate_card_hide_animation.xml new file mode 100644 index 0000000..c4b2288 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/anim/travel_estimate_card_hide_animation.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:fillAfter="true"> + <translate + android:fromYDelta="0" + android:toYDelta="100%" + android:duration="@integer/bottom_card_animation_duration_millis" + android:interpolator="@android:interpolator/fast_out_slow_in" + /> +</set> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/anim/travel_estimate_card_show_animation.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/anim/travel_estimate_card_show_animation.xml new file mode 100644 index 0000000..24b34da --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/anim/travel_estimate_card_show_animation.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:fillAfter="true"> + <translate + android:fromYDelta="100%" + android:toYDelta="0" + android:duration="@integer/bottom_card_animation_duration_millis" + android:interpolator="@android:interpolator/fast_out_slow_in" + /> +</set> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout-w1280dp-h1000dp/list_navigation_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout-w1280dp-h1000dp/list_navigation_template_layout.xml new file mode 100644 index 0000000..ff1e95c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout-w1280dp-h1000dp/list_navigation_template_layout.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <include + android:id="@id/content_container" + android:layout_width="?templateCardContentContainerDefaultWidth" + android:layout_height="0dp" + android:layout_marginStart="?templateCardContentContainerStartMargin" + android:layout_marginTop="?templateCardContentContainerTopMargin" + app:layout_goneMarginTop="?templateCardContentContainerTopMargin" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/action_strip" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="0.0" + app:layout_constraintHeight_default="wrap" + app:layout_constraintHeight_min="?templateCardContentContainerMinHeight" + layout="@layout/card_container" /> + + <include + android:id="@+id/pan_overlay" + android:visibility="gone" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + layout="@layout/pan_overlay" /> + + <!-- The action strip with global actions for the template. --> + <include + android:id="@+id/action_strip" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + layout="@layout/action_strip_view_floating" /> + + <include + android:id="@+id/map_action_strip" + layout="@layout/map_action_strip_view_floating"/> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout-w1280dp-h1000dp/navigation_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout-w1280dp-h1000dp/navigation_template_layout.xml new file mode 100644 index 0000000..147e2c8 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout-w1280dp-h1000dp/navigation_template_layout.xml @@ -0,0 +1,71 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- This is the card that contains the routing information, arrival view, + etc. --> + <include + android:id="@id/content_container" + android:layout_width="?templateRoutingStepsCardContentContainerMinWidth" + android:layout_height="0dp" + android:layout_marginStart="?templateCardContentContainerStartMargin" + android:layout_marginTop="?templateCardContentContainerTopMargin" + android:layout_marginBottom="?templateCardContentContainerBottomMargin" + app:layout_goneMarginTop="?templateCardContentContainerTopMargin" + app:layout_constraintTop_toBottomOf="@id/action_strip" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="0.0" + app:layout_constraintHeight_default="wrap" + app:layout_constraintHeight_min="?templateRoutingStepsCardContentContainerMinHeight" + layout="@layout/steps_card_container"/> + + <!-- The travel estimate (aka "ETA") card at the bottom left of the + screen. --> + <include + layout="@layout/travel_estimate_card_container" + android:visibility="gone" /> + + <include + android:id="@+id/pan_overlay" + android:visibility="gone" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + layout="@layout/pan_overlay" /> + + <!-- The action strip with global actions for the template. --> + <include + android:id="@+id/action_strip" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + layout="@layout/action_strip_view_floating"/> + + <include + android:id="@+id/map_action_strip" + layout="@layout/map_action_strip_view_floating"/> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/list_navigation_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/list_navigation_template_layout.xml new file mode 100644 index 0000000..cba4ed4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/list_navigation_template_layout.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto"> + <include + android:id="@id/content_container" + layout="@layout/card_container" /> + + <include + android:id="@+id/pan_overlay" + android:visibility="gone" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + layout="@layout/pan_overlay" /> + + <!-- The action strip with global actions for the template. --> + <include + android:id="@+id/action_strip" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + layout="@layout/action_strip_view_floating" /> + + <include + android:id="@+id/map_action_strip" + layout="@layout/map_action_strip_view_floating"/> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/navigation_template_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/navigation_template_layout.xml new file mode 100644 index 0000000..c2dfcef --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/navigation_template_layout.xml @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- This is the card that contains the routing information, arrival view, + etc. --> + <include + android:id="@id/content_container" + layout="@layout/steps_card_container"/> + + <!-- The travel estimate (aka "ETA") card at the bottom left of the + screen. --> + <include + layout="@layout/travel_estimate_card_container" + android:visibility="gone" /> + + <include + android:id="@+id/pan_overlay" + android:visibility="gone" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + layout="@layout/pan_overlay" /> + + <!-- The action strip with global actions for the template. --> + <include + android:id="@+id/action_strip" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + layout="@layout/action_strip_view_floating"/> + + <include + android:id="@+id/map_action_strip" + layout="@layout/map_action_strip_view_floating"/> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/steps_card_container.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/steps_card_container.xml new file mode 100644 index 0000000..1eb797f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/steps_card_container.xml @@ -0,0 +1,97 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- +The card has a minimum and maximum heights specific to the routing screen. +The card is anchored to the top left of the screen which should be consistent +with the rest of the card-style templates +--> +<com.android.car.libraries.templates.host.view.widgets.common.BleedingCardView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="?templateCardRoutingContentContainerStyle" + android:focusable="false" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="0.0" + app:layout_constraintHeight_default="wrap" + app:layout_constraintHeight_min="?templateRoutingStepsCardContentContainerMinHeight" + android:layout_marginStart="?templateCardContentContainerStartMargin" + android:layout_marginTop="?templateCardContentContainerTopMargin" + android:layout_marginBottom="?templateCardContentContainerBottomMargin" + android:layout_width="?templateRoutingStepsCardContentContainerMinWidth" + android:layout_height="0dp" + tools:ignore="MissingClass"> + + <!-- A view that shows a message. --> + <include + android:id="@+id/message_view" + android:visibility="gone" + layout="@layout/message_view" /> + + <!-- The container view for progress indicator. --> + <include + android:id="@+id/progress_view" + android:visibility="gone" + layout="@layout/progress_view" /> + + <!-- A container view for the driving instruction, such as the turn icon and + junction image.--> + <LinearLayout + android:id="@+id/steps_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <!-- A detailed view showing the current step. --> + <include + android:id="@+id/detailed_step_view" + layout="@layout/detailed_step_view" /> + + <!-- A divider between the current step and the next step. --> + <View + android:id="@+id/divider" + android:layout_width="match_parent" + android:layout_height="?templateDividerThickness" + android:background="?templateRoutingDividerColor"/> + + <!-- A compact view showing the next step. --> + <include + android:id="@+id/compact_step_view" + layout="@layout/compact_step_view" /> + + <!-- The optional junction image. --> + <FrameLayout + android:id="@+id/junction_image_container" + android:background="?templateRoutingJunctionImageBackgroundColor" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <ImageView + android:id="@+id/junction_image" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" + android:scaleType="centerInside" + android:adjustViewBounds="true" + tools:ignore="ContentDescription" /> + </FrameLayout> + </LinearLayout> +</com.android.car.libraries.templates.host.view.widgets.common.BleedingCardView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/travel_estimate_card_container.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/travel_estimate_card_container.xml new file mode 100644 index 0000000..498304d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/layout/travel_estimate_card_container.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.BleedingCardView + android:id="@+id/travel_estimate_card_container" + xmlns:app="http://schemas.android.com/apk/res-auto" + style="?templateCardRoutingContentContainerStyle" + android:focusable="false" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + android:layout_width="?templateRoutingStepsCardContentContainerMinWidth" + android:layout_height="wrap_content" + android:layout_marginStart="?templateCardContentContainerStartMargin" + android:layout_marginTop="?templateCardContentContainerTopMargin" + android:paddingHorizontal="?templateNavCardPaddingHorizontal" + android:paddingVertical="?templateNavCardSmallPaddingVertical" + xmlns:android="http://schemas.android.com/apk/res/android"> + + <!-- The travel estimate (aka ETA) card. --> + <include + layout="@layout/travel_estimate_view" + android:id="@+id/travel_estimate_view" + android:layout_gravity="center_vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> +</com.android.car.libraries.templates.host.view.widgets.common.BleedingCardView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/transition/place_list_nav_template_transition.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/transition/place_list_nav_template_transition.xml new file mode 100644 index 0000000..bedd592 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/transition/place_list_nav_template_transition.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<transitionSet xmlns:android="http://schemas.android.com/apk/res/android" + android:transitionOrdering="together"> + <fade + android:fadingMode="fade_in_out" + android:duration="?templateUpdateAnimationDurationMilliseconds"/> + <changeBounds + android:duration="?templateUpdateAnimationDurationMilliseconds" + android:interpolator="@interpolator/fast_out_slow_in"/> +</transitionSet> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/transition/routing_card_transition.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/transition/routing_card_transition.xml new file mode 100644 index 0000000..9a18a4e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/transition/routing_card_transition.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<transitionSet xmlns:android="http://schemas.android.com/apk/res/android" + android:transitionOrdering="together"> + <targets> + <target android:targetId="@id/content_container" /> + <target android:targetId="@id/travel_estimate_card_container" /> + <target android:targetId="@id/pan_overlay" /> + </targets> + <fade + android:fadingMode="fade_in_out" + android:duration="@integer/routing_card_animation_duration_millis"> + <targets> + <target android:excludeId="@id/detailed_step_view" /> + <target android:excludeId="@id/travel_estimate_card_container" /> + </targets> + </fade> + <changeBounds + android:duration="@integer/routing_card_animation_duration_millis" + android:interpolator="@interpolator/fast_out_slow_in"/> +</transitionSet> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/values/integers.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/values/integers.xml new file mode 100644 index 0000000..6ac3813 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/presenters/navigation/res/values/integers.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <integer name="routing_card_animation_duration_millis">500</integer> + <integer name="bottom_card_animation_duration_millis">500</integer> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/anim/default_progress_indeterminate_material.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/anim/default_progress_indeterminate_material.xml new file mode 100644 index 0000000..d4d7aea --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/anim/default_progress_indeterminate_material.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- +Copied from the Android SDK's internal resources +Source: //third_party/java/android/android_sdk_linux/platforms/stable/data/res/anim/progress_indeterminate_material.xml +--> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <objectAnimator + android:duration="1333" + android:interpolator="@interpolator/default_trim_start_interpolator" + android:propertyName="trimPathStart" + android:repeatCount="-1" + android:valueFrom="0" + android:valueTo="0.75" + android:valueType="floatType" /> + <objectAnimator + android:duration="1333" + android:interpolator="@interpolator/default_trim_end_interpolator" + android:propertyName="trimPathEnd" + android:repeatCount="-1" + android:valueFrom="0" + android:valueTo="0.75" + android:valueType="floatType" /> + <objectAnimator + android:duration="1333" + android:interpolator="@android:anim/linear_interpolator" + android:propertyName="trimPathOffset" + android:repeatCount="-1" + android:valueFrom="0" + android:valueTo="0.25" + android:valueType="floatType" /> +</set> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/anim/default_progress_indeterminate_rotation_material.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/anim/default_progress_indeterminate_rotation_material.xml new file mode 100644 index 0000000..d267c85 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/anim/default_progress_indeterminate_rotation_material.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- +Copied from the Android SDK's internal resources +Source: //third_party/java/android/android_sdk_linux/platforms/stable/data/res/anim/progress_indeterminate_rotation_material.xml +--> +<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android" + android:duration="6665" + android:interpolator="@android:anim/linear_interpolator" + android:propertyName="rotation" + android:repeatCount="-1" + android:valueFrom="0" + android:valueTo="720" + android:valueType="floatType" /> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color-dpad/template_ripple_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color-dpad/template_ripple_color_selector.xml new file mode 100644 index 0000000..f988a33 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color-dpad/template_ripple_color_selector.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- Due to b/120995067 in order to keep normal ripple effect in touch, the color selector must show +for all states. In rotary and touchpad, per go/aa-focus-design, don't draw ripple when not pressed. +Doing so also avoids "ghost" effect when rapidly moving focus across Views. --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="?templateRippleSelectorColor" + android:state_pressed="true"/> + <item android:color="@android:color/transparent"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color-wheel/template_ripple_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color-wheel/template_ripple_color_selector.xml new file mode 100644 index 0000000..f988a33 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color-wheel/template_ripple_color_selector.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- Due to b/120995067 in order to keep normal ripple effect in touch, the color selector must show +for all states. In rotary and touchpad, per go/aa-focus-design, don't draw ripple when not pressed. +Doing so also avoids "ghost" effect when rapidly moving focus across Views. --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="?templateRippleSelectorColor" + android:state_pressed="true"/> + <item android:color="@android:color/transparent"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color/template_focus_ring_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color/template_focus_ring_color_selector.xml new file mode 100644 index 0000000..ae18391 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color/template_focus_ring_color_selector.xml @@ -0,0 +1,9 @@ +<selector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <!-- Inactive state --> + <item app:templateFocusStateInactive="true" android:color="?templateFocusRingNoAccentColor"/> + + <!-- Default --> + <item android:color="?templateFocusRingColor"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color/template_ripple_color_selector.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color/template_ripple_color_selector.xml new file mode 100644 index 0000000..6ad27df --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/color/template_ripple_color_selector.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- Due to b/120995067 in order to keep normal ripple effect in touch, the color selector must show +for all states. In rotary and touchpad, per go/aa-focus-design, don't draw ripple when not pressed. +Doing so also avoids "ghost" effect when rapidly moving focus across Views. --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:color="?templateRippleSelectorColor"/> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable-night/action_strip_fab_view_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable-night/action_strip_fab_view_background.xml new file mode 100644 index 0000000..2c5f9aa --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable-night/action_strip_fab_view_background.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- The background fill --> + <item> + <shape android:shape="rectangle"> + <solid android:color="?templateActionStripFabBackgroundColorDark"/> + <corners + android:radius="?templateButtonCornerRadius"/> + </shape> + </item> + + <!-- Masked ripple layer --> + <item android:drawable="@drawable/action_strip_button_ripple"/> +</layer-list> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_button_focus_ring.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_button_focus_ring.xml new file mode 100644 index 0000000..3769aab --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_button_focus_ring.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + <corners android:radius="?templateButtonCornerRadius"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + <corners android:radius="?templateButtonCornerRadius"/> + </shape> + </item> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_ripple.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_ripple.xml new file mode 100644 index 0000000..ba659c5 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_ripple.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/template_ripple_color_selector"> + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item + android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/template_ripple_color_selector"/> + <corners android:radius="?templateButtonCornerRadius"/> + </shape> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_view_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_view_background.xml new file mode 100644 index 0000000..01508d9 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_view_background.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- The background fill --> + <item> + <shape android:shape="rectangle"> + <solid android:color="?templateActionStripButtonBackgroundColor"/> + <corners + android:radius="?templateButtonCornerRadius"/> + <stroke + android:width="?templateActionButtonSecondaryBorderWidth" + android:color="?templateActionButtonSecondaryBorderColor" /> + </shape> + </item> + + <!-- Masked ripple layer --> + <item android:drawable="@drawable/action_strip_button_ripple"/> +</layer-list> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_view_focus_ring.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_view_focus_ring.xml new file mode 100644 index 0000000..e97b6ab --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_button_view_focus_ring.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor" /> + <corners android:radius="?templateButtonCornerRadius" /> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor" /> + <corners android:radius="?templateButtonCornerRadius" /> + </shape> + </item> +</selector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_fab_view_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_fab_view_background.xml new file mode 100644 index 0000000..37591ff --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/action_strip_fab_view_background.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- The background fill --> + <item> + <shape android:shape="rectangle"> + <solid android:color="?templateActionStripFabBackgroundColorLight"/> + <corners + android:radius="?templateButtonCornerRadius"/> + </shape> + </item> + + <!-- Masked ripple layer --> + <item android:drawable="@drawable/action_strip_button_ripple"/> +</layer-list> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/back_button_focus_ring.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/back_button_focus_ring.xml new file mode 100644 index 0000000..4f5868f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/back_button_focus_ring.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<inset xmlns:android="http://schemas.android.com/apk/res/android" + android:inset="@dimen/template_back_focus_ring_inset"> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="oval"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="oval"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + </selector> +</inset> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/default_progress_spinner.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/default_progress_spinner.xml new file mode 100644 index 0000000..4f0bf0f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/default_progress_spinner.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<animated-vector xmlns:android="http://schemas.android.com/apk/res/android" + android:drawable="@drawable/default_progress_spinner_medium_thin"> + <target + android:name="progressBar" + android:animation="@anim/default_progress_indeterminate_material" /> + <target + android:name="root" + android:animation="@anim/default_progress_indeterminate_rotation_material" /> +</animated-vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/default_progress_spinner_medium_thin.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/default_progress_spinner_medium_thin.xml new file mode 100644 index 0000000..c5efbed --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/default_progress_spinner_medium_thin.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="48dp" + android:height="48dp" + android:viewportHeight="48" + android:viewportWidth="48"> + <group + android:name="root" + android:translateX="24.0" + android:translateY="24.0"> + <path + android:name="progressBar" + android:fillColor="#00000000" + android:pathData="M0, 0 m 0, -19 a 19,19 0 1,1 0,38 a 19,19 0 1,1 0,-38" + android:strokeColor="?templateLoadingSpinnerColor" + android:strokeLineCap="square" + android:strokeLineJoin="miter" + android:strokeWidth="3" + android:trimPathEnd="0" + android:trimPathOffset="0" + android:trimPathStart="0" /> + </group> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/no_content_view_focus_ring.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/no_content_view_focus_ring.xml new file mode 100644 index 0000000..d488ba4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/no_content_view_focus_ring.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<inset xmlns:android="http://schemas.android.com/apk/res/android" + android:inset="@dimen/template_no_content_view_focus_ring_padding"> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusNoContentAccentColor"/> + <corners android:radius="?templateNoContentFocusCornerRadius"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusNoContentAccentColor"/> + <corners android:radius="?templateNoContentFocusCornerRadius"/> + </shape> + </item> + </selector> +</inset> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/pin_sign_in_view_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/pin_sign_in_view_background.xml new file mode 100644 index 0000000..42be196 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/pin_sign_in_view_background.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="?templateSignInPinBackgroundColor" /> + <corners android:radius="?templateSignInPinCornerRadius" /> +</shape>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_bottom.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_bottom.xml new file mode 100644 index 0000000..a24754e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_bottom.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?templateRippleColor"> + + <!-- Background fill --> + <item> + <selector> + <item android:state_selected="true"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowSelectedBackgroundColor" /> + <corners + android:bottomRightRadius="@dimen/template_row_corner_radius" + android:bottomLeftRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + <item android:state_selected="false"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowBackgroundColor" /> + <corners + android:bottomRightRadius="@dimen/template_row_corner_radius" + android:bottomLeftRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + </selector> + </item> + + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/template_ripple_color_selector"/> + <corners + android:bottomRightRadius="@dimen/template_row_corner_radius" + android:bottomLeftRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + + <!-- Child layer for drawing foreground ring in hovered and focused states. --> + <item> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + <corners + android:bottomLeftRadius="@dimen/template_row_corner_radius" + android:bottomRightRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + <corners + android:bottomLeftRadius="@dimen/template_row_corner_radius" + android:bottomRightRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + </selector> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_middle.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_middle.xml new file mode 100644 index 0000000..e52e937 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_middle.xml @@ -0,0 +1,64 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?templateRippleColor"> + + <!-- Background fill --> + <item> + <selector> + <item android:state_selected="true"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowSelectedBackgroundColor" /> + </shape> + </item> + <item android:state_selected="false"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowBackgroundColor" /> + </shape> + </item> + </selector> + </item> + + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/template_ripple_color_selector"/> + </shape> + </item> + + <!-- Child layer for drawing foreground ring in hovered and focused states. --> + <item> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + </selector> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_top.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_top.xml new file mode 100644 index 0000000..50b092b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_top.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?templateRippleColor"> + + <!-- Background fill --> + <item> + <selector> + <item android:state_selected="true"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowSelectedBackgroundColor" /> + <corners + android:topRightRadius="@dimen/template_row_corner_radius" + android:topLeftRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + <item android:state_selected="false"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowBackgroundColor" /> + <corners + android:topRightRadius="@dimen/template_row_corner_radius" + android:topLeftRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + </selector> + </item> + + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/template_ripple_color_selector"/> + <corners + android:topRightRadius="@dimen/template_row_corner_radius" + android:topLeftRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + + <!-- Child layer for drawing foreground ring in hovered and focused states. --> + <item> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + <corners + android:topLeftRadius="@dimen/template_row_corner_radius" + android:topRightRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + <corners + android:topLeftRadius="@@dimen/template_row_corner_radius" + android:topRightRadius="@dimen/template_row_corner_radius"/> + </shape> + </item> + </selector> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_top_bottom.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_top_bottom.xml new file mode 100644 index 0000000..d7e2b4a --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_sectional_top_bottom.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?templateRippleColor"> + + <!-- Background fill --> + <item> + <selector> + <item android:state_selected="true"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowSelectedBackgroundColor"/> + <corners + android:radius="@dimen/template_row_corner_radius"/> + </shape> + </item> + <item android:state_selected="false"> + <shape android:shape="rectangle"> + <solid android:color="?templateRowBackgroundColor" /> + <corners + android:radius="@dimen/template_row_corner_radius"/> + </shape> + </item> + </selector> + </item> + + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/template_ripple_color_selector"/> + <corners + android:radius="@dimen/template_row_corner_radius"/> + </shape> + </item> + + <!-- Child layer for drawing foreground ring in hovered and focused states. --> + <item> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + <corners + android:radius="@dimen/template_row_corner_radius"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + <corners + android:radius="@dimen/template_row_corner_radius"/> + </shape> + </item> + </selector> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_simple.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_simple.xml new file mode 100644 index 0000000..58dad27 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_background_simple.xml @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?templateRippleColor"> + + <!-- Background fill --> + <item> + <selector> + <item android:state_selected="true"> + <shape + android:shape="rectangle"> + <solid android:color="?templateRowSelectedBackgroundColor" /> + </shape> + </item> + <item android:state_selected="false"> + <shape android:shape="rectangle"> + <solid android:color="@android:color/transparent" /> + </shape> + </item> + </selector> + </item> + + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/template_ripple_color_selector"/> + </shape> + </item> + + <!-- Child layer for drawing foreground ring in hovered and focused states. --> + <item> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + </selector> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_image_placeholder.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_image_placeholder.xml new file mode 100644 index 0000000..5142deb --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/row_image_placeholder.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="oval"> + <solid android:color="?templateRowImagePlaceholderColor" /> +</shape> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/search_bar_icon.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/search_bar_icon.xml new file mode 100644 index 0000000..ade70ba --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/search_bar_icon.xml @@ -0,0 +1,5 @@ +<vector android:height="36dp" android:tint="#FFFFFF" + android:viewportHeight="24.0" android:viewportWidth="24.0" + android:width="36dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@color/template_edit_text_color_selector" android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_grid_item_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_grid_item_background.xml new file mode 100644 index 0000000..1f80d18 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_grid_item_background.xml @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?templateRippleColor"> + + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item + android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/template_ripple_color_selector"/> + <corners + android:radius="@dimen/template_grid_item_corner_radius"/> + </shape> + </item> + + <!-- Child layer for drawing foreground ring in hovered and focused states. --> + <item> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + <corners + android:radius="@dimen/template_grid_item_corner_radius"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="rectangle"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + <corners + android:radius="@dimen/template_grid_item_corner_radius"/> + </shape> + </item> + </selector> + </item> + +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_header_button_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_header_button_background.xml new file mode 100644 index 0000000..00a5abb --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_header_button_background.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<inset xmlns:android="http://schemas.android.com/apk/res/android" + android:inset="@dimen/template_header_button_focus_inset" + android:drawable="@drawable/template_oval_focus_background"/> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_oval_focus_background.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_oval_focus_background.xml new file mode 100644 index 0000000..f8a89ae --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/drawable/template_oval_focus_background.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="?templateRippleColor"> + + <!-- Ripple layer masked inset the thickness of the ring so the ripple layer + is only drawn within the bounds of the ring --> + <item + android:id="@android:id/mask" + android:gravity="center"> + <inset android:inset="@dimen/template_focus_oval_ripple_inset"> + <shape android:shape="oval"> + <solid android:color="@color/template_ripple_color_selector"/> + </shape> + </inset> + </item> + + <!-- Child layer for drawing foreground ring in hovered and focused states. --> + <item> + <selector> + <item android:state_hovered="true" android:state_window_focused="true"> + <shape android:shape="oval"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_hovered" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + <item android:state_focused="true" android:state_window_focused="true"> + <shape android:shape="oval"> + <stroke + android:width="@dimen/template_focus_ring_stroke_width_focused" + android:color="?templateFocusAccentColor"/> + </shape> + </item> + </selector> + </item> +</ripple> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/interpolator/default_trim_end_interpolator.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/interpolator/default_trim_end_interpolator.xml new file mode 100644 index 0000000..de39d96 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/interpolator/default_trim_end_interpolator.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<pathInterpolator xmlns:android="http://schemas.android.com/apk/res/android" + android:pathData="C0.2,0 0.1,1 0.5, 1 L 1,1" /> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/interpolator/default_trim_start_interpolator.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/interpolator/default_trim_start_interpolator.xml new file mode 100644 index 0000000..34eba93 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/interpolator/default_trim_start_interpolator.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<pathInterpolator xmlns:android="http://schemas.android.com/apk/res/android" + android:pathData="L0.5,0 C 0.7,0 0.6,1 1, 1" /> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/layout/template_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/layout/template_view.xml new file mode 100644 index 0000000..67bc95b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/layout/template_view.xml @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!--The template view is marked as focusable in case nothing in the template presenter is focusable. +It will only take focus if no view under its hierarchy is focusable. +This is needed for the touchpad mode, which requires at least one focusable view in the screen. --> +<com.android.car.libraries.templates.host.view.TemplateView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/template_view" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <com.android.car.ui.FocusParkingView + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + + <com.android.car.libraries.apphost.view.SurfaceViewContainer + android:id="@+id/surface_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="gone"/> + + <FrameLayout + android:id="@+id/template_container" + android:layout_width="match_parent" + android:layout_height="match_parent"/> + + <TextView + android:id="@+id/debug_overlay" + android:layout_gravity="bottom|end" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:background="?templateDebugMessageBackgroundColor" + android:layout_margin="10dp" + android:padding="5dp" + android:gravity="end" + style="?templateMessageDebugTextStyle" + android:visibility="gone"/> + +</com.android.car.libraries.templates.host.view.TemplateView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-night/colors.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-night/colors.xml new file mode 100644 index 0000000..799ad34 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-night/colors.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- Color definitions for the UI in the templates. + + !!! IMPORTANT !!! + Do not refer to these colors directly from views. Colors must be referred + to through theme attributes (in attrs.xml). + + !!! IMPORTANT !!! + Do not define colors with a RGB value in here (e.g. #FF9B4DF8). These + colors should all refer to colors defined in @color/default. + + If you need a color that's not there already, make a change to add it and + get that approved. --> +<resources> + <!-- Status bar. --> + <color name="template_status_bar_end_color">@color/default_black</color> + + <!-- Night mode color scheme for map markers. --> + <color name="template_marker_default_background_color">@color/default_white</color> + <color name="template_marker_map_default_content_color">@color/default_black</color> + <color name="template_marker_list_default_content_color">@color/default_white</color> + <color name="template_marker_custom_background_content_color">@color/default_black</color> + <color name="template_marker_default_border_color">@color/default_black</color> + <color name="template_marker_custom_border_color">@color/default_black</color> + <color name="template_anchor_default_background_color">@color/default_white</color> + <color name="template_anchor_border_color">@color/default_black</color> + <color name="template_anchor_dot_color">@color/default_gradient_black_64</color> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w600dp/integers.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w600dp/integers.xml new file mode 100644 index 0000000..65eecaa --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w600dp/integers.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- TODO(b/171342295): Update the dp threshold for deciding number of grid items.--> +<resources> + <integer name="template_grid_items_per_row">3</integer> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp-h520dp/dimens.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp-h520dp/dimens.xml new file mode 100644 index 0000000..99cfd75 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp-h520dp/dimens.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- Markers. --> + <dimen name="template_marker_icon_size">48dp</dimen> + <dimen name="template_marker_image_size">54dp</dimen> +</resources>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp-h520dp/styles.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp-h520dp/styles.xml new file mode 100644 index 0000000..6fef3f0 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp-h520dp/styles.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- The appearance of the markers in the map view. --> + <style name="TextAppearance.Marker" parent="TextAppearance.Template.Body1"> + <item name="android:fontFamily">sans-serif-medium</item> + </style> +</resources>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp/integers.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp/integers.xml new file mode 100644 index 0000000..a59a90d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values-w930dp/integers.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- TODO(b/171342295): Update the dp threshold for deciding number of grid items.--> +<resources> + <integer name="template_grid_items_per_row">4</integer> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/attrs.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/attrs.xml new file mode 100644 index 0000000..958b65b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/attrs.xml @@ -0,0 +1,624 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- The theme attributes for the template UI. + Template layouts and their widgets should not hardcode any styles for the most part but instead + use references to these theme attributes, thus making the look and feel of the template UI + completely defined by this theme. --> + <declare-styleable name="TemplateTheme"> + <!-- Map markers. --> + <!-- The appearance of markers in the map view. --> + <attr name="templateMapMarkerAppearance" format="reference"/> + + <!-- The appearance of markers in the list view. --> + <attr name="templateListMarkerAppearance" format="reference"/> + + <!-- Content containers --> + <!-- Plain content container attributes. A plain container displays the + content on a flat surface with no rounded corners, shadows, etc. --> + <attr name="templatePlainContentContainerStyle" format="reference"/> + <attr name="templatePlainContentContainerWidth" format="dimension"/> + <attr name="templatePlainContentLayoutGravity" format="integer"/> + <attr name="templatePlainContentGravity" format="integer"/> + <attr name="templatePlainContentHorizontalPadding" format="dimension"/> + <attr name="templatePlainContentBackgroundColor" format="color"/> + + <!-- Card content container attributes. --> + <attr name="templateCardContentContainerStyle" format="reference"/> + <attr name="templateCardRoutingContentContainerStyle" format="reference"/> + <attr name="templateCardContentContainerDefaultWidth" format="dimension"/> + <attr name="templateCardContentContainerStartMargin" format="dimension"/> + <attr name="templateCardContentContainerTopMargin" format="dimension"/> + <attr name="templateCardContentContainerBottomMargin" format="dimension"/> + <attr name="templateCardContentContainerMinHeight" format="dimension"/> + + <!-- Headers --> + <!-- The height of the header, which contains a title and other elements + such as the app icon or a back button. --> + <attr name="templateHeaderHeight" format="dimension"/> + + <!-- The text style to be used in the header. --> + <attr name="templateHeaderTextStyle" format="reference"/> + + <!-- The size of the icon of a button in the header (aspect ratio 1:1). --> + <attr name="templateHeaderButtonIconSize" format="dimension"/> + + <!-- The color of the tint for header buttons (e.g. the back button). --> + <attr name="templateHeaderButtonIconTint" format="color"/> + + <!-- The size of the button container in the header (aspect ratio 1:1). --> + <attr name="templateHeaderButtonContainerSize" format="dimension"/> + + <!-- The start margin of the button container in the header. --> + <attr name="templateHeaderButtonStartSpacing" format="dimension"/> + + <!-- Height of the buttons. Both action buttons and FAB. --> + <attr name="templateButtonHeight" format="dimension"/> + + <!-- The corner radius used for non-button components. --> + <attr name="templateCornerRadius" format="dimension"/> + + <!-- The corner radius use for the buttons. --> + <attr name="templateButtonCornerRadius" format="dimension"/> + + <!-- The spacing around the header title. --> + <attr name="templateHeaderTextVerticalSpacing" format="dimension"/> + <attr name="templateHeaderTextStartSpacing" format="dimension"/> + <attr name="templateHeaderTextEndSpacing" format="dimension"/> + <attr name="templateHeaderTextNoIconStartSpacing" format="dimension"/> + + <!-- The background of a header button. Used to implement focus selection + state, ripple effects, etc. --> + <attr name="templateHeaderButtonBackground" format="reference"/> + + <!-- The color of the header background for templates where it is used. --> + <attr name="templateHeaderBackgroundColor" format="reference"/> + + <!-- Theme attributes for rows in lists. --> + <!-- The background color of a row container view. + This color is used only for color contrast checking, and not for actual coloring of the grid item background. --> + <attr name="templateRowBackgroundColor" format="color"/> + + <!-- The background color of a row container view in the selected state.--> + <attr name="templateRowSelectedBackgroundColor" format="color"/> + + <!-- The color of the placeholder while an image is loading in a row.--> + <attr name="templateRowImagePlaceholderColor" format="color"/> + + <!-- Sign in Template --> + <attr name="templateSignInContainerStyle" format="reference"/> + <attr name="templateSignInMethodViewMaxWidth" format="dimension"/> + <attr name="templateSignInInstructionTextStyle" format="reference"/> + <attr name="templateSignInProviderSignInButtonStyle" format="reference"/> + <attr name="templateSignInPinTextStyle" format="reference"/> + <attr name="templateSignInPinBackgroundColor" format="color"/> + <attr name="templateSignInPinCornerRadius" format="dimension"/> + <attr name="templateSignInPinBackground" format="reference"/> + <attr name="templateSignInPinPadding" format="dimension"/> + <attr name="templateSignInQRCodeImageWidth" format="dimension"/> + <attr name="templateSignInAdditionalTextStyle" format="reference"/> + <attr name="templateSignInErrorMessageStyle" format="reference"/> + <attr name="templateSignInInputViewStyle" format="reference"/> + + <!-- Hyperlink Text --> + <attr name="templateHyperlinkTextColor" format="color"/> + + <!-- The default tint for an icon in a row. --> + <attr name="templateRowDefaultIconTint" format="color"/> + + <!-- The padding to the left and right of a row's contents. --> + <attr name="templateRowHorizontalPadding" format="dimension"/> + + <!-- The padding to the left and right of a half row's contents. --> + <attr name="templateRowHorizontalHalfPadding" format="dimension"/> + + <!-- The padding to the left and right of the text inside of a row. --> + <attr name="templateRowTextHorizontalPadding" format="dimension"/> + + <!-- The padding to the left and right of the text inside of a half row. --> + <attr name="templateRowTextHorizontalHalfPadding" format="dimension"/> + + <!-- The padding to the bottom of a half list's contents. --> + <attr name="templateHalfListBottomPadding" format="dimension"/> + + <!-- The vertical padding inside of a half row. --> + <attr name="templateHalfListPaddingVertical" format="dimension"/> + + <!-- The style of the title of a row. --> + <attr name="templateRowTitleStyle" format="reference"/> + + <!-- The style of the secondary text of a row. --> + <attr name="templateRowSecondaryTextStyle" format="reference"/> + + <!-- The style of the section header. --> + <attr name="templateRowSectionHeaderStyle" format="reference"/> + + <!-- The style of the text that indicates a list is empty. --> + <attr name="templateRowListEmptyTextStyle" format="reference"/> + + <!-- The image dimensions (for PaneTemplate) in the row list template. --> + <attr name="templateRowListToLargeImageRatio" format="dimension"/> + <attr name="templateRowListLargeImageContainerMaxWidth" format="dimension"/> + <attr name="templateRowListLargeImageAspectRatio" format="dimension"/> + + <!-- Padding between the (PaneTemplate) image and row list --> + <attr name="templateRowListAndImagePadding" format="dimension"/> + + <!-- The background for the top, middle, and bottom rows, and for a single + row that is both top and bottom, for lists that show rows with + a rounded corner background. These backgrounds can be used for + providing rounded corners to the list, or they can all simply be set + to the same background, if such an effect is not desired. --> + <attr name="templateRowBackgroundSectionalTop" format="reference"/> + <attr name="templateRowBackgroundSectionalMiddle" format="reference"/> + <attr name="templateRowBackgroundSectionalBottom" format="reference"/> + <attr name="templateRowBackgroundSectionalTopBottom" format="reference"/> + + <!-- The margin at the bottom of a section in a list. --> + <attr name="templateRowBackgroundSectionalBottomMargin" format="dimension"/> + + <!-- The placeholder for asynchronously loaded row images. --> + <attr name="templateRowImagePlaceholder" format="reference"/> + + <!-- The background for rows with square corners. --> + <attr name="templateRowBackgroundSimple" format="reference"/> + + <!-- The minimum margin between the rows's title and the edge + of the row. --> + <attr name="templateRowMinHeight" format="dimension"/> + + <!-- Attributes of the icons in a row of a list. --> + <attr name="templateRowIconStyle" format="reference"/> + <attr name="templateRowIconSize" format="dimension"/> + <attr name="templateRowRadioButtonSize" format="dimension"/> + <attr name="templateRowImageSizeSmall" format="dimension"/> + <attr name="templateRowImageSizeLarge" format="dimension"/> + + <!-- Attributes for the marker label that is displayed in the list rows + when the row has a location attached to it. --> + <!-- The minimum width of the marker label. The actual width can expand based on the content string.--> + <attr name="templateRowMarkerMinSize" format="dimension"/> + + <!-- The margin from the edge of the row to the marker label. --> + <attr name="templateRowMarkerLabelMargin" format="dimension"/> + + <!-- The height of a selection element's container in a row (e.g. a toggle + or radio button. --> + <attr name="templateRowSelectionContainerHeight" format="dimension"/> + + <!-- The half row minimum height. --> + <attr name="templateHalfRowMinHeight" format="dimension"/> + + <!-- The paddings used around the half row. --> + <attr name="templateHalfRowHorizontalPadding" format="dimension"/> + <attr name="templateHalfRowVerticalPadding" format="dimension"/> + + <!-- The paddings used around the full row. --> + <attr name="templateFullRowStartPadding" format="dimension"/> + <attr name="templateFullRowEndPadding" format="dimension"/> + + <!-- The spacing used between the image and text of the half row. --> + <attr name="templateHalfRowImageToTextSpacing" format="dimension"/> + + <!-- The spacing used between the primary and secondary text of the half row. --> + <attr name="templateHalfRowTextToTextSpacing" format="dimension"/> + + <!-- Size of the images used within the half list row. --> + <attr name="templateHalfRowImageSize" format="dimension"/> + + <!-- The full list row's chevron icon on the right side. --> + <attr name="templateFullRowChevronIcon" format="reference"/> + + <!-- The full list row's chevron height. --> + <attr name="templateFullRowChevronHeight" format="dimension"/> + + <!-- The full list row's chevron width. --> + <attr name="templateFullRowChevronWidth" format="dimension"/> + + <!-- The half list row's chevron icon on the right side. --> + <attr name="templateHalfRowChevronIcon" format="reference"/> + + <!-- The background for the grid item and its containers. --> + <attr name="templateGridItemBackground" format="reference"/> + + <!-- The background color for the grid item. + This color is used only for color contrast checking, and not for actual coloring of the grid item background. --> + <attr name="templateGridItemBackgroundColor" format="reference"/> + + <!-- The style of the title of a grid item. --> + <attr name="templateGridItemTitleStyle" format="reference"/> + + <!-- The style of the secondary text line below the title of a grid item. --> + <attr name="templateGridItemTextStyle" format="reference"/> + + <!-- The tint variations for an icon in a grid item. --> + <attr name="templateGridItemDefaultIconTint" format="color"/> + + <!-- The maximum width of a grid item text container. --> + <attr name="templateGridItemTextContainerMaxWidth" format="dimension"/> + + <!-- The bottom padding of the text inside of a grid item. --> + <attr name="templateGridItemTextBottomPadding" format="dimension"/> + + <!-- The bottom padding of the image inside of a grid item. --> + <attr name="templateGridItemImageBottomPadding" format="dimension"/> + + <!-- The padding for the grid items. --> + <attr name="templateGridItemHorizontalSpacing" format="dimension"/> + <attr name="templateGridItemVerticalSpacing" format="dimension"/> + + <!-- The number of grid items in a grid row. --> + <attr name="templateGridItemsPerRow" format="integer"/> + + <!-- Theme attributes for the grid. --> + <attr name="templateGridStyle" format="reference"/> + <attr name="templateGridRecyclerViewPaddingRight" format="reference"/> + + <!-- The style of the text that indicates a grid is empty. --> + <attr name="templateGridEmptyTextStyle" format="reference"/> + + <!-- Action buttons and FABs. --> + <!-- The margin between action buttons. --> + <attr name="templateActionButtonMargin" format="dimension"/> + + <!-- The style of an action displayed as a button. --> + <attr name="templateActionButtonStyle" format="reference"/> + + <!-- The style of the text inside of an action button. --> + <attr name="templateActionButtonTextStyle" format="reference"/> + + <!-- The style of the text inside of a primary action button. --> + <attr name="templateActionButtonPrimaryTextStyle" format="reference"/> + + <!-- The default background color of an action displayed as a button. --> + <attr name="templateActionButtonDefaultBackgroundColor" format="color" /> + + <!-- The background color of a primary action displayed as a button. --> + <attr name="templateActionButtonPrimaryBackgroundColor" format="color" /> + + <!-- The default foreground drawable of an action displayed as a button. --> + <attr name="templateActionButtonForeground" format="reference" /> + + <!-- The default background drawable of an action displayed as a button. --> + <attr name="templateActionButtonBackground" format="reference" /> + + <!-- The height of an action button. --> + <attr name="templateActionButtonHeight" format="dimension"/> + + <!-- The minimum touch area size for action buttons. --> + <attr name="templateActionButtonTouchTargetSize" format="dimension"/> + + <!-- Whether buttons in the action button list (e.g. used in PaneTemplate) stretch to fill the horizontal space. --> + <attr name="templateActionButtonListButtonStretchHorizontal" format="boolean"/> + + <!-- Whether OEM colors should override 3P provided colors. --> + <attr name="templateActionButtonUseOemColors" format="boolean"/> + + <!-- The alignment of contents in buttons in the action button list (e.g. used in PaneTemplate). + The possible values are: + <ul> + <li>0: center (default) + <li>1: left + <li>2: right + </ul> --> + <attr name="templateActionButtonPrimaryHorizontalOrder" format="integer"/> + + <!-- The gravity of action button list (e.g. used in MessageTemplate, SigninTemplate and LongMessageTemplate). + The possible values are: + <ul> + <li>0: center (default) + <li>1: bottom + </ul> --> + <attr name="templateActionButtonListGravity" format="integer"/> + <!-- The alignment of contents in buttons in the action button list (e.g. used in PaneTemplate). + The possible values are: + <ul> + <li>0: center (default) + <li>1: left + <li>2: right + </ul> --> + <attr name="templateActionButtonListButtonContentAlignment" format="integer"/> + + <!-- The maximum width of a button in the action button list, e.g. used in PaneTemplate. + This value will be used only when `templateActionButtonListFillHorizontalSpace` is set to `true`. --> + <attr name="templateActionButtonListButtonMaxWidth" format="dimension"/> + + <!-- The spacing between the content and the aligned side in a button in the action button list, e.g. used in PaneTemplate. + This value will be used only when `templateActionButtonListButtonContentAlignment` is set to 1 (left) or 2 (right). + When this value is used, `templateActionIconTextStartSpacing`, `templateActionIconTextEndSpacing`, and `templateActionTextHorizontalSpacing` will be ignored. --> + <attr name="templateActionButtonSideAlignmentSpacing" format="dimension"/> + + <!-- The vertical spacing of the action button list row, e.g. used in PaneTemplate. --> + <attr name="templateActionButtonListRowVerticalSpacing" format="dimension"/> + + <!-- The size of an icon inside of an action button or FAB. --> + <attr name="templateActionIconSize" format="dimension"/> + <attr name="templateActionIconSizeMin" format="dimension"/> + <attr name="templateActionIconSizeMax" format="dimension"/> + + <!-- The spacing between the start side of a FAB or button and the action icon. + The spacing is applied only when the button has both icon and text. --> + <attr name="templateActionIconTextStartSpacing" format="dimension"/> + + <!-- The spacing between the end side of a FAB or button and the action icon. + The spacing is applied only when the button has both icon and text. --> + <attr name="templateActionIconTextEndSpacing" format="dimension"/> + + <!-- The spacing between the icon and the text in a FAB or button. --> + <attr name="templateActionIconToTextSpacing" format="dimension"/> + + <!-- The spacing between the start and end sides of a FAB or button and the action text. + The spacing is applied only when the button only has the text. --> + <attr name="templateActionTextHorizontalSpacing" format="dimension"/> + + <!-- The default tint of an icon inside of an action button or FAB. --> + <attr name="templateActionDefaultIconTint" format="color"/> + + <!-- The min width of an action button or FAB with text. --> + <attr name="templateActionWithTextMinWidth" format="integer"/> + + <!-- The min width of an action button or FAB with an icon only. --> + <attr name="templateActionWithoutTextMinWidth" format="dimension"/> + + <!-- The max ems of the text inside of a button when there is no icon. --> + <attr name="templateActionButtonTextMaxEmsNoIcon" format="integer"/> + + <!-- The max ems of the text inside of a button when there is an icon. --> + <attr name="templateActionButtonTextMaxEmsWithIcon" format="integer"/> + + <!-- The max ems of the text inside of a FAB when there is no icon. --> + <attr name="templateFabTextMaxEmsNoIcon" format="integer"/> + + <!-- The max ems of the text inside of a FAB when there is an icon. --> + <attr name="templateFabTextMaxEmsWithIcon" format="integer"/> + + <!-- The width of the border of a secondary button. --> + <attr name="templateActionButtonSecondaryBorderWidth" format="dimension"/> + + <!-- The color of the border of a secondary button. --> + <attr name="templateActionButtonSecondaryBorderColor" format="color"/> + + <!-- The margin between buttons in the action strip. --> + <attr name="templateActionStripButtonMargin" format="dimension"/> + + <!-- The padding around the action strip. --> + <attr name="templateActionStripPadding" format="dimension"/> + + <!-- The color of buttons in the action strip in full-screen templates. --> + <attr name="templateActionStripButtonBackgroundColor" format="color"/> + + <!-- The light color of buttons in the action strip as FABs. --> + <attr name="templateActionStripFabBackgroundColorLight" format="color"/> + + <!-- The dark color of buttons in the action strip as FABs. --> + <attr name="templateActionStripFabBackgroundColorDark" format="color"/> + + <!-- The appearance of action strip buttons as FABs. --> + <attr name="templateActionStripFabAppearance" format="reference"/> + + <!-- The appearance of action strip buttons in full-screen templates. --> + <attr name="templateActionStripFullTemplateFabAppearance" format="reference"/> + + <!-- Ripple attributes, common for all elements using ripples. --> + <attr name="templateRippleColor" format="color"/> + <attr name="templateRippleSelectorColor" format="color"/> + + <!-- Toggles and radio buttons. --> + <attr name="templateToggleWidth" format="dimension"/> + <attr name="templateToggleHeight" format="dimension"/> + <attr name="templateToggleInactiveTrackColor" format="color"/> + <attr name="templateToggleInactiveThumbColor" format="color"/> + <attr name="templateToggleActiveTrackColor" format="color"/> + <attr name="templateToggleActiveThumbColor" format="color"/> + <attr name="templateRadioButtonSize" format="dimension"/> + + <!-- Clickable spans. --> + <attr name="templateClickableSpanHighlightForegroundColor" format="color"/> + <attr name="templateClickableSpanHighlightBackgroundColor" format="color"/> + + <!-- Focus. --> + <attr name="templateFocusAccentColor" format="color"/> + <attr name="templateFocusNoContentAccentColor" format="color"/> + <attr name="templateFocusStateInactive" format="boolean"/> + <attr name="templateFocusRingColor" format="color"/> + <attr name="templateFocusRingNoAccentColor" format="color"/> + + <!-- Editable text. --> + <attr name="templateEditTextStyle" format="reference"/> + <attr name="templateEditTextActiveColor" format="color"/> + <attr name="templateEditTextEnabledColor" format="color"/> + <attr name="templateEditTextErrorColor" format="color"/> + <attr name="templateEditTextDisabledColor" format="color"/> + <attr name="templateEditTextErrorVerticalSpacing" format="dimension"/> + <attr name="templateEditTextErrorHorizontalSpacing" format="dimension"/> + + <!-- Search bar. --> + <!-- Maximum width of the search bar. --> + <attr name="templateSearchBarMaxWidth" format="dimension"/> + + <!-- The search icon on the left side of the search bar. --> + <attr name="templateSearchBarIcon" format="reference"/> + + <!-- Images. --> + <!-- The size of a large image. --> + <attr name="templateLargeImageSize" format="dimension"/> + + <!-- The minimum size of a large image. --> + <attr name="templateLargeImageSizeMin" format="dimension"/> + + <!-- The maximum size of a large image. --> + <attr name="templateLargeImageSizeMax" format="dimension"/> + + <!-- Message template. --> + <!-- The default tint of an icon inside of the message template. --> + <attr name="templateMessageDefaultIconTint" format="color"/> + + <!-- The style of the text in the title of the message template. --> + <attr name="templateMessageTitleTextStyle" format="reference"/> + + <!-- The spacing used on top of the title of the message template. --> + <attr name="templateMessageTitleTopSpacing" format="dimension"/> + + <!-- The spacing used on top of the buttons of the message template. --> + <attr name="templateMessageButtonsTopSpacing" format="dimension"/> + + <!-- The spacing used on top and bottom of the sticky buttons. --> + <attr name="templateStickyButtonsVerticalSpacing" format="dimension"/> + + <!-- The style of the text in the long message template. --> + <attr name="templateMessageLongTextStyle" format="reference"/> + + <!-- The color of the text and background of the debug message showing the + callstack in error screens. --> + <attr name="templateMessageDebugTextStyle" format="reference"/> + <attr name="templateDebugMessageBackgroundColor" format="color"/> + + <!-- Navigation routing template. --> + <!-- The maximum heights and width of an image in a text span. + body2 and body3 variants should be used along text views configured + with those respective font sizes. --> + <attr name="templateRoutingImageSpanBody2MaxHeight" format="dimension"/> + <attr name="templateRoutingImageSpanBody3MaxHeight" format="dimension"/> + + <!-- The horizontal and vertical padding values in the routing card. --> + <attr name="templateNavCardPaddingHorizontal" format="dimension"/> + <attr name="templateNavCardPaddingVertical" format="dimension"/> + + <!-- The small vertical padding value in the routing card and the travel estimate card. --> + <attr name="templateNavCardSmallPaddingVertical" format="dimension"/> + + <!-- The horizontal padding value between the icon and the distance text in the routing card. --> + <attr name="templateRoutingStepsCardIconToDistanceSpacingHorizontal" format="dimension"/> + + <!-- Ratio of the image span (width/height) based on the max height value --> + <attr name="templateRoutingImageSpanRatio" format="float"/> + + <!-- The dimensions of the large image in the routing card (ratio 1:1). --> + <attr name="templateNavCardLargeImageSize" format="dimension"/> + <attr name="templateNavCardLargeImageSizeMin" format="dimension"/> + <attr name="templateNavCardLargeImageSizeMax" format="dimension"/> + + <!-- The dimensions of the small image in the routing card (ratio 1:1). --> + <attr name="templateNavCardSmallImageSize" format="dimension"/> + <attr name="templateNavCardSmallImageSizeMin" format="dimension"/> + <attr name="templateNavCardSmallImageSizeMax" format="dimension"/> + + <!-- Size of the large text of the routing card. --> + <attr name="templateNavCardLargeTextSize" format="dimension"/> + + <!-- Size of the xlarge text of the routing card. --> + <attr name="templateNavCardXLargeTextSize" format="dimension"/> + + <!-- The fallback text color used in the routing card when the OEM-provided default text color is not used. --> + <attr name="templateNavCardFallbackContentColor" format="color"/> + + <!-- The style of the distance text in the detailed step view. --> + <attr name="templateRoutingDistanceStyle" format="dimension"/> + + <!-- The style of the description text in the detailed step view. --> + <attr name="templateRoutingDescriptionStyle" format="dimension"/> + + <!-- The style of the description text in the compact step view. --> + <attr name="templateRoutingCompactDescriptionStyle" format="dimension"/> + + <!-- The style of the description text in the travel estimate view. --> + <attr name="templateRoutingTravelEstimateStyle" format="dimension"/> + + <!-- The height of the container of the lanes image. --> + <attr name="templateRoutingLanesImageContainerHeight" format="dimension"/> + + <!-- The vertical padding of the container of the lanes image. --> + <attr name="templateRoutingLanesImageContainerVerticalPadding" format="dimension"/> + + <!-- The horizontal padding of the container of the lanes image. --> + <attr name="templateRoutingLanesImageContainerHorizontalPadding" format="dimension"/> + + <!-- The color of the background of the lanes image. --> + <attr name="templateRoutingLanesImageBackgroundColor" format="color"/> + + <!-- The color of the background of the junction image. --> + <attr name="templateRoutingJunctionImageBackgroundColor" format="color"/> + + <!-- The style of the primary text for the title in the message view. --> + <attr name="templateRoutingMessagePrimaryStyle" format="reference"/> + + <!-- The style of the secondary text for the message view. --> + <attr name="templateRoutingMessageSecondaryStyle" format="reference"/> + + <!-- The horizontal inner padding between the image and the primary text in the message view. --> + <attr name="templateRoutingMessageInnerPaddingHorizontal" format="dimension"/> + + <!-- The vertical inner padding between the primary and secondary texts in the message view. --> + <attr name="templateRoutingMessageInnerPaddingVertical" format="dimension"/> + + <!-- The width and min height of the container which shows the current and + next steps in the routing card. --> + <attr name="templateRoutingStepsCardContentContainerMinWidth" format="dimension"/> + <attr name="templateRoutingStepsCardContentContainerMinHeight" format="dimension"/> + + <!-- The color of the divider in the routing card. --> + <attr name="templateRoutingDividerColor" format="dimension"/> + + <!-- Status bar. --> + <!-- Status bar gradient background start and end colors. --> + <attr name="templateStatusBarStartColor" format="color"/> + <attr name="templateStatusBarEndColor" format="color"/> + + <!-- Defines a minimum top padding for the presenter in case there is no status bar, + i.e. Widescreen Android Auto does not have status bar. --> + <attr name="templateStatusBarMinimumTopPadding" format="dimension"/> + + <!-- No content view. --> + <attr name="templateNoContentFocusCornerRadius" format="dimension"/> + + <!-- Loading spinner. --> + <attr name="templateLoadingSpinnerStyle" format="reference"/> + <attr name="templateLoadingSpinnerColor" format="color"/> + + <!-- Attributes of most dividers used throughout the UI. --> + <attr name="templateDividerColor" format="color"/> + <attr name="templateDividerThickness" format="dimension"/> + + <!-- A fraction used for implementing margins that adapt to the width of the screen. + For example, some templates may have a 12% minimum margin (with respect of the + screen width) on either side. This value should be set to 1.0 minus twice + the margin percentage (in other words, it is the width of the content itself). --> + <attr name="templateAdaptiveWidthFraction" format="float"/> + + <!-- The duration in milliseconds of a template transition animation e.g. a cross fade. --> + <attr name="templateUpdateAnimationDurationMilliseconds" format="integer"/> + + <!-- Standard colors --> + <attr name="templateStandardBlue" format="color"/> + <attr name="templateStandardRed" format="color"/> + <attr name="templateStandardGreen" format="color"/> + <attr name="templateStandardYellow" format="color"/> + + <!-- The spacing used between controls e.g. buttons. --> + <attr name="templateControlToControlSpacingHorizontal" format="dimension"/> + + <!-- The maximum number of rows in a list view. --> + <attr name="templateListMaxLength" format="integer"/> + + <!-- The maximum number of grid items in a grid view. --> + <attr name="templateGridMaxLength" format="integer"/> + + <!-- Whether or not NavState events should be sent to the system via NavigationManager --> + <attr name="templateSendNavStateToSystem" format="boolean"/> + </declare-styleable> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/colors.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/colors.xml new file mode 100644 index 0000000..31833f1 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/colors.xml @@ -0,0 +1,137 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- Color definitions for the UI in the templates. + + !!! IMPORTANT !!! + Do not refer to these colors directly from views. Colors must be referred + to through theme attributes (in attrs.xml). + + !!! IMPORTANT !!! + Do not define colors with a RGB value in here (e.g. #FF9B4DF8). These + colors should all refer to colors defined in @color/default. + + If you need a color that's not there already, make a change to add it and + get that approved. --> +<resources> + + <color name="template_black">@color/default_black</color> + <color name="template_black_64">@color/default_gradient_black_64</color> + + <color name="template_white">@color/default_white</color> + <color name="template_white_16">@color/default_gradient_white_16</color> + <color name="template_white_24">@color/default_gradient_white_24</color> + <color name="template_white_46">@color/default_gradient_white_46</color> + <color name="template_white_56">@color/default_gradient_white_56</color> + <color name="template_white_72">@color/default_gradient_white_72</color> + + <color name="template_gray_50">@color/default_gray_50</color> + <color name="template_gray_100">@color/default_gray_100</color> + <color name="template_gray_400">@color/default_gray_400</color> + <color name="template_gray_500">@color/default_gray_500</color> + <color name="template_gray_700">@color/default_gray_700</color> + <color name="template_gray_846">@color/default_gray_846</color> + <color name="template_gray_868">@color/default_gray_868</color> + <color name="template_gray_878">@color/default_gray_878</color> + <color name="template_gray_900">@color/default_gray_900</color> + <color name="template_gray_928">@color/default_gray_928</color> + + <!-- Standard colors --> + <color name="template_standard_red">@color/car_app_ui_standard_red</color> + <color name="template_standard_red_dark">@color/car_app_ui_standard_red_dark</color> + <color name="template_standard_green">@color/car_app_ui_standard_green</color> + <color name="template_standard_green_dark">@color/car_app_ui_standard_green_dark</color> + <color name="template_standard_blue">@color/car_app_ui_standard_blue</color> + <color name="template_standard_blue_dark">@color/car_app_ui_standard_blue_dark</color> + <color name="template_standard_yellow">@color/car_app_ui_standard_yellow</color> + <color name="template_standard_yellow_dark">@color/car_app_ui_standard_yellow_dark</color> + + <!-- Colors derived from OEM values. These values should be customized + through car_ui. --> + <color name="template_icon_tint_color">@color/car_ui_text_color_primary</color> + <color name="template_text_color_primary">@color/car_ui_text_color_primary</color> + <color name="template_card_background_color">@color/car_ui_activity_background_color</color> + <color name="template_plain_content_background_color">@color/car_ui_activity_background_color</color> + + <!-- Color of text inside of a content view. --> + <color name="template_content_text_color">@color/template_gray_50</color> + + <!-- Color of secondary text inside of a content view. --> + <color name="template_content_text_color_secondary">@color/template_gray_500</color> + + <!-- Color of focus text inside of a content view. --> + <color name="template_content_text_color_focus">@color/template_standard_blue</color> + + <!-- Map markers. --> + <color name="template_marker_default_background_color">@color/template_white</color> + <color name="template_marker_map_default_content_color">@color/template_black</color> + <color name="template_marker_list_default_content_color">@color/template_white</color> + <color name="template_marker_custom_background_content_color">@color/template_white</color> + <color name="template_marker_default_border_color">@color/template_gray_700</color> + <color name="template_marker_custom_border_color">@color/template_white</color> + <color name="template_anchor_default_background_color">@color/template_black</color> + <color name="template_anchor_border_color">@color/template_white</color> + <color name="template_anchor_dot_color">@color/template_white_56</color> + + <!-- Content Containers. --> + <color name="template_container_background">@color/template_black</color> + <color name="template_content_button_color">@color/template_white</color> + + <!-- Cards. --> + <color name="template_card_content_container_border_color">@color/template_white_24</color> + + <!-- Toggles. --> + <color name="template_toggle_inactive_track">@color/template_white</color> + <color name="template_toggle_inactive_thumb">@color/template_gray_400</color> + <color name="template_toggle_active_track">@color/template_standard_blue</color> + <color name="template_toggle_active_thumb">@color/template_standard_blue</color> + + <!-- Actions. --> + <color name="template_action_button_default_background_color">@color/car_app_ui_action_button_default_background_color</color> + + <!-- Action strip FABs. --> + <color name="template_action_strip_fab_background_color">@color/car_app_ui_floating_button_default_background_color</color> + <color name="template_action_strip_fab_content_color">@color/car_app_ui_floating_button_default_text_color</color> + + <!-- Message template. --> + <color name="template_message_debug_text_color">@color/default_message_debug_text_color</color> + + <!-- Sign-in template. --> + <color name="template_sign_in_error_message_color">@color/template_standard_red</color> + <color name="template_sign_in_additional_text_color">@color/template_gray_500</color> + + <!-- Read-only text. --> + <color name="template_read_only_text_background_color">@color/car_app_ui_read_only_text_background_color</color> + + <!-- Ripples. --> + <color name="template_ripple_color">@color/default_controller_ripple_color</color> + <color name="template_ripple_selector_color">@color/default_controller_ripple_selector_color</color> + + <!-- Status bar. --> + <color name="template_status_bar_end_color">@color/template_white</color> + + <!-- Edit text. --> + <color name="template_edit_text_color_selector">@color/default_edit_text_color_selector</color> + <color name="template_edit_text_active_color">@color/car_app_ui_edit_text_active_color</color> + <color name="template_edit_text_enabled_color">@color/car_app_ui_edit_text_enabled_color</color> + <color name="template_edit_text_error_color">@color/car_app_ui_edit_text_error_color</color> + <color name="template_edit_text_disabled_color">@color/car_app_ui_edit_text_disabled_color</color> + + <!-- Loading spinner. --> + <color name="template_loading_spinner_color">@color/template_standard_blue</color> + +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/dimens.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/dimens.xml new file mode 100644 index 0000000..defd4c4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/dimens.xml @@ -0,0 +1,278 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + + <!-- Adaptive height and width spacing key lines. --> + <dimen name="template_height_keyline_0">@dimen/default_height_keyline_0</dimen> + <dimen name="template_height_keyline_1">@dimen/default_height_keyline_1</dimen> + <dimen name="template_height_keyline_2">@dimen/default_height_keyline_2</dimen> + <dimen name="template_height_keyline_3">@dimen/default_height_keyline_3</dimen> + <dimen name="template_width_keyline_0">@dimen/default_width_keyline_0</dimen> + <dimen name="template_width_keyline_1">@dimen/default_width_keyline_1</dimen> + <dimen name="template_width_keyline_2">@dimen/default_width_keyline_2</dimen> + <dimen name="template_width_keyline_3">@dimen/default_width_keyline_3</dimen> + + <!-- Padding. --> + <dimen name="template_padding_0">@dimen/default_padding_0</dimen> + <dimen name="template_padding_1">@dimen/default_padding_1</dimen> + <dimen name="template_padding_2">@dimen/default_padding_2</dimen> + <dimen name="template_padding_3">@dimen/default_padding_3</dimen> + <dimen name="template_padding_4">@dimen/default_padding_4</dimen> + <dimen name="template_padding_5">@dimen/default_padding_5</dimen> + <dimen name="template_padding_6">@dimen/default_padding_6</dimen> + <dimen name="template_padding_7">@dimen/default_padding_7</dimen> + <dimen name="template_padding_8">@dimen/default_padding_8</dimen> + + <!-- Minimum tap target size, used for buttons, etc. --> + <dimen name="template_min_tap_target_size">68dp</dimen> + + <!-- + Edge column definition + + These values are used to align UI elements at the edge of the screen, even if + the elements belong to completely different layouts. + --> + <dimen name="template_edge_column_width">@dimen/default_edge_column_width</dimen> + <dimen name="template_edge_column_margin">@dimen/default_edge_column_margin</dimen> + + <!-- + This dimen represents the entire width of the edge column, margin included. + + It should remain equal to: + 2 * template_edge_column_margin + template_edge_column_width + --> + <dimen name="template_edge_column_width_padded">@dimen/default_edge_column_width_padded</dimen> + + <!-- A minimum margin value used to offset some of the text views + in the UI. --> + <dimen name="template_min_text_margin">8dp</dimen> + + <!-- Common dimensions. --> + <item name="template_adaptive_width_fraction" format="float" type="dimen">.76</item> + <dimen name="template_button_touch_target_size">@dimen/car_app_ui_touch_target_size</dimen> + + <!-- Images. --> + <dimen name="template_large_image_size">@dimen/car_app_ui_large_image_size</dimen> + <dimen name="template_large_image_size_min">0dp</dimen> + <dimen name="template_large_image_size_max">128dp</dimen> + + <!-- Markers. --> + <dimen name="template_marker_pointer_width">16dp</dimen> + <dimen name="template_marker_pointer_height">10dp</dimen> + <dimen name="template_marker_text_horizontal_padding">8dp</dimen> + <dimen name="template_marker_icon_size">32dp</dimen> + <dimen name="template_marker_stroke">2dp</dimen> + <dimen name="template_marker_corner_radius">8dp</dimen> + <dimen name="template_marker_image_size">36dp</dimen> + <dimen name="template_marker_image_corner_radius">4dp</dimen> + <dimen name="template_marker_padding">2dp</dimen> + + <!-- Plain content containers. --> + <dimen name="template_plain_content_container_width">400dp</dimen> + <dimen name="template_plain_content_container_padding">10dp</dimen> + <dimen name="template_plain_content_horizontal_padding">@dimen/car_app_ui_content_horizontal_margin</dimen> + + <!-- Card content containers. --> + <dimen name="template_card_content_container_default_width">384dp</dimen> + <dimen name="template_card_content_container_min_width">360dp</dimen> + <dimen name="template_card_content_container_max_width">416dp</dimen> + <dimen name="template_card_content_container_oem_max_width">500dp</dimen> + <dimen name="template_card_content_container_start_margin">@dimen/car_app_ui_card_start_margin</dimen> + <dimen name="template_card_content_container_top_margin">@dimen/car_app_ui_card_top_margin</dimen> + <dimen name="template_card_content_container_bottom_margin">@dimen/template_width_keyline_1</dimen> + <dimen name="template_card_content_container_min_height">200dp</dimen> + + <!-- Card container width fractions. Zero means we use the layout_width, not the fraction. --> + <item name="template_card_content_container_width_fraction" format="float" type="dimen">.48</item> + <item name="template_steps_card_content_container_width_fraction" format="float" type="dimen">.4</item> + + <!-- Corresponds to +5 elevation in material spec: + https://standards.google/guidelines/google-material/styles/elevation.html#spec --> + <dimen name="template_card_content_container_elevation">12dp</dimen> + <dimen name="template_card_content_container_border_width">2dp</dimen> + + <!-- Headers. --> + <dimen name="template_header_height">68dp</dimen> + <dimen name="template_header_text_vertical_spacing">@dimen/car_app_ui_card_header_text_padding_vertical</dimen> + <dimen name="template_header_text_horizontal_spacing">@dimen/car_app_ui_card_header_text_padding_horizontal</dimen> + <dimen name="template_header_button_icon_size">@dimen/car_app_ui_card_header_image_size</dimen> + <dimen name="template_header_button_start_spacing">0dp</dimen> + <dimen name="template_header_text_no_icon_start_spacing">@dimen/car_app_ui_card_header_no_button_text_margin_start</dimen> + + <dimen name="template_header_button_focus_inset">4dp</dimen> + + <!-- Lists. --> + <dimen name="template_row_list_max_width">@dimen/default_canvas_max_width</dimen> + <dimen name="template_row_list_no_scrollbar_start_padding_card">@dimen/template_padding_1</dimen> + + <!-- Row List. --> + <dimen name="template_row_list_large_image_container_max_width">480dp</dimen> + + <!-- Rows. --> + <dimen name="template_row_min_height">72dp</dimen> + <dimen name="template_row_corner_radius">@dimen/default_list_item_corner_radius</dimen> + <dimen name="template_row_icon_size">36dp</dimen> + <dimen name="template_row_image_size_small">36dp</dimen> + <dimen name="template_row_image_size_large">64dp</dimen> + <dimen name="template_row_action_max_width">240dp</dimen> + <dimen name="template_row_marker_min_size">36dp</dimen> + <dimen name="template_row_marker_label_margin">15dp</dimen> + <dimen name="template_row_horizontal_padding">@dimen/template_width_keyline_1</dimen> + <dimen name="template_row_horizontal_half_padding">@dimen/template_width_keyline_0</dimen> + <dimen name="template_row_text_horizontal_padding">@dimen/template_width_keyline_0</dimen> + <dimen name="template_row_text_horizontal_half_padding">@dimen/template_padding_0</dimen> + <dimen name="template_row_background_sectional_bottom_margin">@dimen/template_padding_3</dimen> + <dimen name="template_half_list_bottom_padding">@dimen/default_height_keyline_3</dimen> + <dimen name="template_half_row_padding_vertical">@dimen/default_padding_4</dimen> + + <!-- Half Rows. --> + <dimen name="template_half_row_horizontal_padding">@dimen/car_app_ui_half_row_horizontal_padding</dimen> + <dimen name="template_half_row_vertical_padding">@dimen/car_app_ui_half_row_vertical_padding</dimen> + <dimen name="template_half_row_image_to_text_margin">@dimen/car_app_ui_half_row_image_to_text_spacing</dimen> + <dimen name="template_half_row_text_to_text_margin">@dimen/car_app_ui_half_row_text_to_text_spacing</dimen> + <dimen name="template_half_row_image_size">@dimen/car_app_ui_half_row_image_size</dimen> + + <!-- Full Rows. --> + <dimen name="template_full_row_start_padding">@dimen/car_app_ui_full_row_start_padding</dimen> + <dimen name="template_full_row_end_padding">@dimen/car_app_ui_full_row_end_padding</dimen> + <dimen name="template_full_row_chevron_size">@dimen/car_ui_list_item_supplemental_icon_size</dimen> + + <!-- Grids. --> + <dimen name="template_grid_max_width">@dimen/default_paged_list_view_max_content_width</dimen> + + <!-- Grid items. --> + <dimen name="template_grid_item_image_bottom_padding">@dimen/car_app_ui_grid_item_image_to_text_spacing_vertical</dimen> + <dimen name="template_grid_item_text_container_max_width">196dp</dimen> + <dimen name="template_grid_item_text_bottom_padding">@dimen/car_app_ui_grid_item_text_to_text_spacing_vertical</dimen> + <dimen name="template_grid_item_horizontal_spacing">@dimen/template_width_keyline_0</dimen> + <dimen name="template_grid_item_vertical_spacing">@dimen/car_app_ui_grid_item_vertical_spacing</dimen> + + <dimen name="template_grid_item_corner_radius">@dimen/default_list_item_corner_radius</dimen> + <dimen name="template_grid_item_image_background_size">50dp</dimen> + + <!-- Sign-In Template. --> + <dimen name="template_sign_in_method_view_max_width">@dimen/car_app_ui_sign_in_method_max_width</dimen> + <dimen name="template_sign_in_pin_text_letter_spacing" format="float" type="dimen">.6</dimen> + <dimen name="template_sign_in_input_error_message_text_size">18dp</dimen> + <dimen name="template_sign_in_input_additional_text_size">18dp</dimen> + <dimen name="template_sign_in_qr_code_image_width">250dp</dimen> + + <!-- Read-only Text. --> + <dimen name="template_read_only_text_padding">@dimen/car_app_ui_read_only_text_padding</dimen> + + <!-- Scrollbar. --> + <dimen name="template_paged_list_scrollbar_width">@dimen/template_edge_column_width_padded</dimen> + <dimen name="template_paged_list_scrollbar_width_card">68dp</dimen> + + <!-- Dividers. --> + <dimen name="template_divider_thickness">1dp</dimen> + + <!-- Action buttons and FABs. --> + <!-- Vertical margin in action button. --> + <dimen name="template_action_button_vertical_margin">@dimen/template_padding_2</dimen> + + <!-- Action button list row vertical spacing. --> + <dimen name="template_action_button_list_row_vertical_spacing">@dimen/template_padding_2</dimen> + + <!-- Min width common to buttons and FABs, when the action has text. --> + <dimen name="template_action_with_text_min_width">156dp</dimen> + + <!-- The size of an icon inside of a FAB or button. --> + <dimen name="template_action_icon_size">@dimen/car_app_ui_button_image_size</dimen> + <dimen name="template_action_icon_size_min">0dp</dimen> + <!-- This max size should align with the max size we can set on the system toolbar. --> + <dimen name="template_action_icon_size_max">88dp</dimen> + + <!-- The spacing between the start side of a FAB or button and the action icon. + The spacing is applied only when the button has both icon and text. --> + <dimen name="template_action_icon_text_start_spacing">@dimen/car_app_ui_icon_button_start_spacing</dimen> + + <!-- The spacing between the end side of a FAB or button and the action icon. + The spacing is applied only when the button has both icon and text. --> + <dimen name="template_action_icon_text_end_spacing">@dimen/car_app_ui_icon_button_end_spacing</dimen> + + <!-- The spacing between the icon and the text in a FAB or button. --> + <dimen name="template_action_icon_to_text_spacing">@dimen/car_app_ui_icon_button_image_to_text_spacing</dimen> + + <!-- The spacing between the start and end sides of a FAB or button and the action text. + The spacing is applied only when the button only has the text. --> + <dimen name="template_action_text_horizontal_spacing">@dimen/car_app_ui_button_text_horizontal_spacing</dimen> + + <!-- The width of the border for a secondary button, e.g. a button rendered + in the action strip in a full screen template. --> + <dimen name="template_action_button_secondary_border_width">2dp</dimen> + + <!-- The padding around the action strip. --> + <dimen name="template_action_strip_padding">@dimen/car_ui_padding_3</dimen> + + <!-- The vertical margin of the sticky buttons. --> + <dimen name="template_sticky_buttons_vertical_spacing">@dimen/car_ui_padding_2</dimen> + + <!-- Toggles and radio buttons. --> + <dimen name="template_toggle_width">44dp</dimen> + <dimen name="template_toggle_height">24dp</dimen> + <dimen name="template_radio_button_size">24dp</dimen> + + <!-- Focus. --> + <dimen name="template_focus_ring_stroke_width_hovered">@dimen/default_focus_ring_stroke_width_hovered</dimen> + <dimen name="template_focus_ring_stroke_width_focused">@dimen/default_focus_ring_stroke_width_focused</dimen> + <dimen name="template_back_focus_ring_inset">4dp</dimen> + <dimen name="template_search_focus_ring_inset">6dp</dimen> + <dimen name="template_action_fab_focus_ring_size">56dp</dimen> + <dimen name="template_focus_oval_ripple_inset">4dp</dimen> + + <!-- Routing. --> + <dimen name="template_routing_image_span_body2_max_height">@dimen/default_body2_line_height</dimen> + <dimen name="template_routing_image_span_body3_max_height">@dimen/default_body3_line_height</dimen> + <dimen name="template_nav_card_large_image_size">@dimen/car_app_ui_nav_card_large_image_size</dimen> + <dimen name="template_nav_card_large_image_size_min">0dp</dimen> + <dimen name="template_nav_card_large_image_size_max">128dp</dimen> + <dimen name="template_nav_card_small_image_size">@dimen/car_app_ui_nav_card_small_image_size</dimen> + <dimen name="template_nav_card_small_image_size_min">0dp</dimen> + <dimen name="template_nav_card_small_image_size_max">72dp</dimen> + <dimen name="template_routing_lanes_image_container_height">55dp</dimen> + + <dimen name="template_nav_card_padding_vertical">@dimen/car_app_ui_nav_card_padding_vertical</dimen> + <dimen name="template_nav_card_padding_horizontal">@dimen/car_app_ui_nav_card_padding_horizontal</dimen> + <dimen name="template_nav_card_small_padding_vertical">@dimen/car_app_ui_nav_card_small_padding_vertical</dimen> + <dimen name="template_steps_card_image_to_text_spacing_horizontal">@dimen/car_app_ui_nav_card_image_to_text_spacing_horizontal</dimen> + <dimen name="template_steps_card_image_to_text_spacing_vertical">@dimen/car_app_ui_nav_card_image_to_text_spacing_vertical</dimen> + + <dimen name="template_steps_card_content_container_min_width">320dp</dimen> + <dimen name="template_steps_card_content_container_max_width">372dp</dimen> + <dimen name="template_steps_card_content_container_oem_max_width">500dp</dimen> + <dimen name="template_steps_card_content_container_min_height">100dp</dimen> + + <!-- Message template. --> + <dimen name="template_message_title_top_spacing">@dimen/car_app_ui_image_to_text_spacing_vertical</dimen> + <dimen name="template_message_buttons_top_spacing">@dimen/car_app_ui_text_to_control_spacing_vertical</dimen> + <dimen name="template_message_debug_text_size">14dp</dimen> + <dimen name="template_message_debug_text_line_height">18dp</dimen> + + <!-- Search template. --> + <dimen name="template_search_bar_max_width">580dp</dimen> + + <!-- Status bar. --> + <dimen name="template_status_bar_minimum_top_padding">0dp</dimen> + + <!-- No content view. --> + <dimen name="template_no_content_view_focus_corner_radius">@dimen/default_no_content_focus_background_corner_radius</dimen> + + <!-- Use a negative padding value to draw the focus ring outside the no content view. --> + <dimen name="template_no_content_view_focus_ring_padding">-8dp</dimen> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/dimens_default.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/dimens_default.xml new file mode 100644 index 0000000..d462eba --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/dimens_default.xml @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- Standard dimensions for all template widgets. + + These dimensions can be referenced directly from widgets without going + through a theme attribute. + + See go/watevra-visd. --> +<resources> + + <dimen name="default_padding_0">4dp</dimen> + <dimen name="default_padding_1">8dp</dimen> + <dimen name="default_padding_2">12dp</dimen> + <dimen name="default_padding_3">16dp</dimen> + <dimen name="default_padding_4">24dp</dimen> + <dimen name="default_padding_5">32dp</dimen> + <dimen name="default_padding_6">48dp</dimen> + <dimen name="default_padding_7">64dp</dimen> + <dimen name="default_padding_8">96dp</dimen> + + <dimen name="default_height_keyline_0">0dp</dimen> + <dimen name="default_height_keyline_1">8dp</dimen> + <dimen name="default_height_keyline_2">12dp</dimen> + <dimen name="default_height_keyline_3">16dp</dimen> + <dimen name="default_width_keyline_0">4dp</dimen> + <dimen name="default_width_keyline_1">8dp</dimen> + <dimen name="default_width_keyline_2">8dp</dimen> + <dimen name="default_width_keyline_3">16dp</dimen> + + <dimen name="default_touch_target_minimum_size">68dp</dimen> + <dimen name="default_edge_column_width">@dimen/default_touch_target_minimum_size</dimen> + <dimen name="default_edge_column_margin">@dimen/default_width_keyline_1</dimen> + <dimen name="default_edge_column_width_padded">84dp</dimen> + + <dimen name="default_body1_text_size">32dp</dimen> + <dimen name="default_body1_line_height">40dp</dimen> + <item name="default_body1_letter_spacing" format="float" type="dimen">0.0094</item> + + <dimen name="default_body2_text_size">28dp</dimen> + <dimen name="default_body2_line_height">36dp</dimen> + <item name="default_body2_letter_spacing" format="float" type="dimen">0.0107</item> + + <dimen name="default_body3_text_size">24dp</dimen> + <dimen name="default_body3_line_height">32dp</dimen> + <item name="default_body3_letter_spacing" format="float" type="dimen">0.0250</item> + + <dimen name="default_display1_text_size">56dp</dimen> + <dimen name="default_display1_line_height">64dp</dimen> + <item name="default_display1_letter_spacing" format="float" type="dimen">0.0000</item> + + <dimen name="default_display2_text_size">44dp</dimen> + <dimen name="default_display2_line_height">52dp</dimen> + <item name="default_display2_letter_spacing" format="float" type="dimen">0.0023</item> + + <dimen name="default_display3_text_size">36dp</dimen> + <dimen name="default_display3_line_height">44dp</dimen> + <item name="default_display3_letter_spacing" format="float" type="dimen">0.0055</item> + + <!-- PagedListView --> + <dimen name="default_paged_list_view_max_content_width">704dp</dimen> + + <!-- Focus --> + <dimen name="default_focus_ring_stroke_width_hovered">4dp</dimen> + <dimen name="default_focus_ring_stroke_width_focused">6dp</dimen> + + <!-- Apps Space --> + <dimen name="default_canvas_max_width">794dp</dimen> + <dimen name="default_list_item_corner_radius">8dp</dimen> + <dimen name="default_no_content_focus_background_corner_radius">16dp</dimen> + + <!-- Edit Text --> + <!-- Use a negative padding value to draw the focus ring outside the edit text. --> + <dimen name="default_edit_text_focus_ring_padding">-6dp</dimen> + <dimen name="default_edit_text_focus_ring_radius">12dp</dimen> + +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/floats.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/floats.xml new file mode 100644 index 0000000..970dd93 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/floats.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- GridItems. --> + <!-- Alpha value for the secondary text line in a grid item. --> + <item name="template_grid_item_text_alpha" format="float" type="dimen">0.72</item> + + <!-- Navigation routing template. --> + <!-- Ratio of the image span (width/height) that should be used alongside text. --> + <item name="template_routing_image_span_ratio" format="float" type="dimen">3.0</item> + + <!-- Row list template. --> + <!-- Aspect ratio of the PaneTemplate large image. --> + <item name="template_row_list_large_image_aspect_ratio" format="float" type="dimen">1.75</item> + + <!-- Width ratio of the large image relative to the row list width --> + <item name="template_row_list_to_large_image_ratio" format="float" type="dimen">0.33</item> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/integers.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/integers.xml new file mode 100644 index 0000000..10a9467 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/integers.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <integer name="template_grid_items_per_row">2</integer> + + <!-- Actions. --> + <!-- + Max ems to display for the button text if there is no icon. + Actual spec is 11ems, but TextView inserts the ellipsize after the maxems + value, so here the value is 11-1 to account for the ellipsize. + --> + <integer name="template_action_button_text_max_ems_no_icon">10</integer> + + <!-- + Max ems to display for the button text if there is an icon. + Actual spec is 10ems, but TextView inserts the ellipsize after the maxems + value, so here the value is 10-1 to account for the ellipsize. + --> + <integer name="template_action_button_text_max_ems_with_icon">9</integer> + + <!-- + Max ems to display for the fab text if there is no icon. + Actual spec is 7ems, but TextView inserts the ellipsize after the maxems + value, so here the value is 7-1 to account for the ellipsize. + --> + <integer name="template_fab_text_max_ems_no_icon">6</integer> + + <!-- + Max ems to display for the fab text if there is an icon. + Actual spec is 6ems, but TextView inserts the ellipsize after the maxems + value, so here the value is 6-1 to account for the ellipsize. + --> + <integer name="template_fab_text_max_ems_with_icon">5</integer> + + <!-- + Max ems to display for the provider sign-in button text. + TODO(b/184195457): remove the custom maxEms limit when we remove the limit for all + --> + <integer name="template_provider_sign_in_button_text_max_ems">100</integer> + + <!-- Duration for the animation of templates switching. --> + <integer name="template_update_animation_duration_millis">300</integer> + + <!-- Layout gravity for content areas (e.g content vertical alignment in Sign In Template + content).--> + <integer name="template_plain_content_layout_gravity">@integer/car_app_ui_content_layout_gravity</integer> + + <!-- Content gravity for content areas (e.g. component horizontal alignment in Sign In + Template content). --> + <integer name="template_plain_content_gravity">@integer/car_app_ui_content_gravity</integer> + + <!-- Gravity integer values (to be used as part of gravity overlayable attributes. --> + <integer name="gravity_bottom">80</integer> + <integer name="gravity_center">17</integer> + <integer name="gravity_center_horizontal">1</integer> + <integer name="gravity_center_vertical">16</integer> + <integer name="gravity_end">8388613</integer> + <integer name="gravity_left">3</integer> + <integer name="gravity_no_gravity">0</integer> + <integer name="gravity_right">5</integer> + <integer name="gravity_start">8388611</integer> + <integer name="gravity_top">48</integer> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/styles.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/styles.xml new file mode 100644 index 0000000..0779b34 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/styles.xml @@ -0,0 +1,391 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources xmlns:tools="http://schemas.android.com/tools"> + <!-- Style definitions for the UI in the templates. + + !!! IMPORTANT !!! + Do not refer to these styles directly from views. Styles must be referred + to through theme attributes (in attrs.xml). --> + <style name="Template" parent="Theme.Template"/> + + <!-- The appearance of the markers in the map view. --> + <style name="TextAppearance.Marker" parent="TextAppearance.Template.Body3.Medium"/> + + <style name="MarkerAppearance" parent="TextAppearance.Marker"> + <item name="markerDefaultBackgroundColor">@color/template_marker_default_background_color</item> + <item name="markerCustomBackgroundContentColor">@color/template_marker_custom_background_content_color</item> + <item name="markerDefaultBorderColor">@color/template_marker_default_border_color</item> + <item name="markerCustomBorderColor">@color/template_marker_custom_border_color</item> + <item name="markerPointerWidth">@dimen/template_marker_pointer_width</item> + <item name="markerPointerHeight">@dimen/template_marker_pointer_height</item> + <item name="markerStroke">@dimen/template_marker_stroke</item> + <item name="markerCornerRadius">@dimen/template_marker_corner_radius</item> + <item name="markerPadding">@dimen/template_marker_padding</item> + + <item name="anchorDefaultBackgroundColor">@color/template_anchor_default_background_color</item> + <item name="anchorBorderColor">@color/template_anchor_border_color</item> + <item name="anchorDotColor">@color/template_anchor_dot_color</item> + + <item name="markerTextHorizontalPadding">@dimen/template_marker_text_horizontal_padding</item> + <item name="markerIconSize">@dimen/template_marker_icon_size</item> + <item name="markerImageSize">@dimen/template_marker_image_size</item> + <item name="markerImageCornerRadius">@dimen/template_marker_image_corner_radius</item> + + <item name="markerListIconSize">?templateHalfRowImageSize</item> + </style> + + <style name="MarkerAppearance.Template.Map" parent="MarkerAppearance"> + <item name="markerDefaultContentColor">@color/template_marker_map_default_content_color</item> + </style> + + <style name="MarkerAppearance.Template.List" parent="MarkerAppearance"> + <item name="markerDefaultContentColor">@color/template_marker_list_default_content_color</item> + </style> + + <!-- The style of an action button. --> + <style name="Widget.Template.ActionButton"> + <item name="android:foreground">?templateActionButtonForeground</item> + <item name="android:background">?templateActionButtonBackground</item> + </style> + + <!-- The style of a FAB. --> + <style name="Widget.Template.Fab"> + <item name="android:clickable">true</item> + <item name="android:focusable">true</item> + <item name="android:orientation">horizontal</item> + <item name="android:gravity">center</item> + <item name="android:elevation">6dp</item> + </style> + + <!-- The appearance of a FAB when actually displayed as floating, e.g. + over a map surface. --> + <style name="FabAppearance.Template.Fab" parent="Widget.Template.Fab"> + <item name="android:background">@drawable/action_strip_fab_view_background</item> + <item name="android:foreground">@drawable/action_strip_button_view_focus_ring</item> + <item name="fabDefaultContentColor">@color/template_action_strip_fab_content_color</item> + </style> + + <!-- The appearance of a FAB when displayed in a full screen template. --> + <style name="FabAppearance.Template.FullTemplate" parent="Widget.Template.Fab"> + <item name="android:background">@drawable/action_strip_button_view_background</item> + <item name="android:foreground">@drawable/action_strip_button_view_focus_ring</item> + <item name="fabDefaultContentColor">@color/default_white</item> + </style> + + <!-- The style of a loading spinner. --> + <style name="Widget.Template.Spinner"> + <item name="android:indeterminateDrawable">@drawable/default_progress_spinner</item> + <item name="android:indeterminate">true</item> + </style> + + <!-- The style of a content container. --> + <style name="Widget.Template.Container"> + <item name="android:background">@drawable/car_ui_activity_background</item> + </style> + + <!-- The style of a content container on a surface template. --> + <style name="Widget.Template.Container.Surface"> + <item name="android:background">@android:color/transparent</item> + <item name="android:paddingRight">@dimen/template_plain_content_container_padding</item> + </style> + + <!-- The style of a plain content container. --> + <style name="Widget.Template.Container.Plain" /> + + <!-- The style of a card content container. --> + <style name="Widget.Template.Container.Card"> + <item name="cardBackgroundColor">@color/template_card_background_color</item> + <item name="cardTextColor">@color/template_text_color_primary</item> + <item name="cardFallbackDarkBackgroundColor">@color/default_gray_928</item> + <item name="cardFallbackLightBackgroundColor">@color/template_white</item> + <item name="cardBorderColor">@color/template_card_content_container_border_color</item> + <item name="cardBorderWidth">@dimen/template_card_content_container_border_width</item> + <item name="cardRadius">?templateCornerRadius</item> + <item name="android:elevation">@dimen/template_card_content_container_elevation</item> + </style> + + <!-- The style of a card content container with a content view (e.g. list). --> + <style name="Widget.Template.Container.Card.Content"> + <item name="cardWidthFraction">@dimen/template_card_content_container_width_fraction</item> + <item name="cardMinWidth">@dimen/template_card_content_container_min_width</item> + <item name="cardMaxWidth">@dimen/template_card_content_container_max_width</item> + <item name="cardOemWidth">@dimen/car_app_ui_card_width</item> + <item name="cardOemMaxWidth">@dimen/template_card_content_container_oem_max_width</item> + </style> + + <!-- The style of a card content container with the routing information. --> + <style name="Widget.Template.Container.Card.Content.Routing"> + <item name="cardWidthFraction">@dimen/template_steps_card_content_container_width_fraction</item> + <item name="cardMinWidth">@dimen/template_steps_card_content_container_min_width</item> + <item name="cardMaxWidth">@dimen/template_steps_card_content_container_max_width</item> + <item name="cardOemWidth">@dimen/car_app_ui_nav_card_width</item> + <item name="cardOemMaxWidth">@dimen/template_steps_card_content_container_oem_max_width</item> + </style> + + <!-- The style of a button in a content view. --> + <style name="Widget.Template.ContentButton"> + <item name="android:scaleType">center</item> + <item name="android:tint">@color/template_content_button_color</item> + <item name="android:tintMode">src_atop</item> + </style> + + <!-- The style of a list of rows, plain style. --> + <style name="Widget.Template.RowList"> + <item name="listWidthFraction">@dimen/template_adaptive_width_fraction</item> + <item name="listMaxWidth">@dimen/template_row_list_max_width</item> + <item name="listScrollbarWidth">@dimen/template_paged_list_scrollbar_width</item> + <item name="listShowScrollbarDivider">false</item> + </style> + + <!-- The style of a list of rows, card style. --> + <style name="Widget.Template.RowList.Card"> + <item name="listScrollbarWidth">@dimen/template_paged_list_scrollbar_width_card</item> + <item name="listNoScrollBarStartPadding">@dimen/template_row_list_no_scrollbar_start_padding_card</item> + <!-- Card widths are not adaptive. --> + <item name="listWidthFraction">-1.0</item> + <item name="listShowScrollbarDivider">true</item> + </style> + + <!-- The style of a grid, plain style. --> + <style name="Widget.Template.Grid"> + <item name="listWidthFraction">@dimen/template_adaptive_width_fraction</item> + <item name="listMaxWidth">@dimen/template_grid_max_width</item> + <item name="listScrollbarWidth">@dimen/template_paged_list_scrollbar_width</item> + <item name="listShowScrollbarDivider">false</item> + </style> + + <!-- The style of the title text in a grid item. --> + <style name="Widget.Template.Text.GridItemTitle"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.GridItemTitle</item> + <item name="android:gravity">center</item> + </style> + + <!-- The style of the secondary text line below the title in a grid item. --> + <style name="Widget.Template.Text.GridItemText"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.GridItemText</item> + <item name="android:gravity">center</item> + </style> + + <!-- The style of text that indicates a grid is empty --> + <style name="Widget.Template.GridEmpty" parent="Widget.CarAppUi.RowSecondary"> + <item name="android:maxLines">2</item> + <item name="android:gravity">center</item> + <!-- TODO(b/174717763): overriding body3's letter spacing since it seems to cause + centered text to be incorrectly cropped --> + <item name="android:letterSpacing">0</item> + </style> + + <style name="Widget.Template.Routing"/> + + <!-- The style of the text showing the title of the routing card when in + arrived state. --> + <style name="Widget.Template.Routing.MessagePrimary"> + <item name="android:textAppearance">@style/TextAppearance.Template.NavCardLargeText</item> + <item name="android:maxLines">2</item> + </style> + + <!-- The style of the text showing the destination address in the routing + card when in arrived state. --> + <style name="Widget.Template.Routing.MessageSecondary"> + <item name="android:textAppearance">@style/TextAppearance.Template.NavCardSmallText</item> + <item name="android:maxLines">2</item> + <item name="android:lineSpacingExtra">5sp</item> + </style> + + <style name="Widget.Template.Routing.Distance"> + <item name="android:textAppearance">@style/TextAppearance.Template.NavCardXLargeText</item> + </style> + + <style name="Widget.Template.Routing.Description"> + <item name="android:textAppearance">@style/TextAppearance.Template.NavCardMediumText</item> + <item name="android:maxLines">2</item> + </style> + + <style name="Widget.Template.Routing.CompactDescription"> + <item name="android:textAppearance">@style/TextAppearance.Template.NavCardSmallText</item> + <item name="android:maxLines">1</item> + </style> + + <style name="Widget.Template.Routing.TravelEstimate"> + <item name="android:textAppearance">@style/TextAppearance.Template.NavCardSmallText</item> + <item name="android:maxLines">1</item> + </style> + + <!-- Routing card textAppearance --> + <style name="TextAppearance.Template.NavCardSmallText" parent="TextAppearance.CarUi.Body3" /> + <style name="TextAppearance.Template.NavCardMediumText" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.Template.NavCardLargeText" parent="TextAppearance.CarUi.Body1" > + <item name="android:textSize">?templateNavCardLargeTextSize</item> + </style> + <style name="TextAppearance.Template.NavCardXLargeText" parent="TextAppearance.CarUi.Body1"> + <item name="android:textSize">?templateNavCardXLargeTextSize</item> + </style> + + <!-- The style of the title text inside of the message template. --> + <style name="Widget.Template.Text.Message"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.TextBlock</item> + <item name="android:maxLines">2</item> + <item name="android:gravity">center</item> + </style> + + <!-- The style of the title text inside of the long message template. --> + <style name="Widget.Template.Text.LongMessage"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.TextBlock</item> + <item name="android:maxLines">2147483647</item> + </style> + + <!-- The style of text inside of a debug info inside of the message template. --> + <style name="Widget.Template.Debug" parent="Widget.Template.Text.Body3"> + <item name="android:maxLines">1024</item> + <item name="android:textColor">@color/template_message_debug_text_color</item> + <item name="android:fontFamily">monospace</item> + <item name="android:textSize">@dimen/template_message_debug_text_size</item> + <item name="android:lineHeight" tools:ignore="NewApi">@dimen/template_message_debug_text_line_height</item> + </style> + + <style name="Widget.Template.SignIn"/> + + <!-- The style of the container of sign-in content. --> + <style name="Widget.Template.SignIn.Container"> + <item name="android:gravity">?templatePlainContentGravity</item> + <item name="android:layout_gravity">?templatePlainContentLayoutGravity</item> + <item name="android:layout_marginHorizontal">?templatePlainContentHorizontalPadding</item> + </style> + + <!-- The style of the instruction text inside the sign-in template. --> + <style name="Widget.Template.SignIn.InstructionText"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.SignInHeader</item> + <item name="android:maxLines">2</item> + <item name="android:gravity">center</item> + </style> + + <!-- The style of a provider sign-in button. --> + <style name="Widget.Template.SignIn.ProviderSignInbutton" parent="Widget.Template.ActionButton"> + <item name="textMaxEms">@integer/template_provider_sign_in_button_text_max_ems</item> + </style> + + <!-- The style of the PIN text inside the sign-in template. --> + <style name="Widget.Template.SignIn.PinText"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.ReadOnlyText</item> + <item name="android:maxLines">1</item> + <item name="android:gravity">center</item> + <item name="android:letterSpacing">@dimen/template_sign_in_pin_text_letter_spacing</item> + </style> + + <!-- The style of the additional text inside the sign-in template. --> + <style name="Widget.Template.SignIn.AdditionalText"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.SignInLegal</item> + <item name="android:maxLines">3</item> + <item name="android:gravity">center</item> + <item name="android:textColor">@color/template_sign_in_additional_text_color</item> + <item name="android:textColorLink">?templateHyperlinkTextColor</item> + <item name="android:linksClickable">true</item> + </style> + + <!-- The style of the error message inside the sign-in template. --> + <style name="Widget.Template.SignIn.ErrorMessage" parent="Widget.Template.Text.Body3"> + <item name="android:maxLines">1</item> + <item name="android:gravity">start</item> + <item name="android:textColor">@color/template_sign_in_error_message_color</item> + <item name="android:textSize">@dimen/template_sign_in_input_error_message_text_size</item> + <item name="android:layout_marginStart">?templateEditTextErrorHorizontalSpacing</item> + <item name="android:layout_marginTop">?templateEditTextErrorVerticalSpacing</item> + </style> + + <!-- The style of the text in an action button. --> + <style name="Widget.Template.Text.ActionButton"> + <item name="android:textAppearance">@style/TextAppearance.CarAppUi.ButtonText</item> + <item name="android:textColor">@color/car_app_ui_action_button_text_color</item> + </style> + + <!-- Standard styles for all template widgets. + + These styles can be referenced directly from widgets without using a + theme attribute. + + See go/watevra-visd. --> + <style name="TextAppearance.Template" parent="TextAppearance.Design"/> + + <!-- Styles for display text, meant for larger text like titles and such. --> + <style name="TextAppearance.Template.Display1" parent="TextAppearance.Design.Display1"/> + <style name="TextAppearance.Template.Display2" parent="TextAppearance.Design.Display2"/> + <style name="TextAppearance.Template.Display2.Medium" parent="TextAppearance.Design.Display2.Medium"/> + <style name="TextAppearance.Template.Display3" parent="TextAppearance.Design.Display3"/> + + <!-- Styles for body text, meant for smaller text like list row contents. --> + <style name="TextAppearance.Template.Body1" parent="TextAppearance.Design.Body1"/> + <style name="TextAppearance.Template.Body2" parent="TextAppearance.Design.Body2"/> + <style name="TextAppearance.Template.Body3" parent="TextAppearance.Design.Body3"/> + <style name="TextAppearance.Template.Body3.Medium" parent="TextAppearance.Design.Body3.Medium"/> + + <style name="Widget"/> + <style name="Widget.Template"/> + + <!-- Base style for text widgets. --> + <style name="Widget.Template.Text"> + <item name="android:maxLines">1</item> + <item name="android:ellipsize">end</item> + </style> + + <!-- Styles for text widgets. There's one per typographic style as declare in + the specification: + https://designguidelines.withgoogle.com/automotive-os-apps/visual-foundations/typography.html#typography-scale-grid-references + + Note certain paragraph-level attributes such as `lineHeight` can't be + folded into the `TextAppearance` hence why we need to declare these as + part of a style. --> + <style name="Widget.Template.Text.Display1"> + <item name="android:textAppearance">@style/TextAppearance.Template.Display1</item> + <item name="android:lineHeight" tools:ignore="NewApi">@dimen/default_display1_line_height</item> + <item name="android:letterSpacing">@dimen/default_display1_letter_spacing</item> + </style> + + <style name="Widget.Template.Text.Display2"> + <item name="android:textAppearance">@style/TextAppearance.Template.Display2</item> + <item name="android:lineHeight" tools:ignore="NewApi">@dimen/default_display2_line_height</item> + <item name="android:letterSpacing">@dimen/default_display2_letter_spacing</item> + </style> + + <style name="Widget.Template.Text.Display3"> + <item name="android:textAppearance">@style/TextAppearance.Template.Display3</item> + <item name="android:lineHeight" tools:ignore="NewApi">@dimen/default_display3_line_height</item> + <item name="android:letterSpacing">@dimen/default_display3_letter_spacing</item> + </style> + + <style name="Widget.Template.Text.Body1"> + <item name="android:textAppearance">@style/TextAppearance.Template.Body1</item> + <item name="android:lineHeight" tools:ignore="NewApi">@dimen/default_body1_line_height</item> + <item name="android:letterSpacing">@dimen/default_body1_letter_spacing</item> + </style> + + <style name="Widget.Template.Text.Body2"> + <item name="android:textAppearance">@style/TextAppearance.Template.Body2</item> + <item name="android:lineHeight" tools:ignore="NewApi">@dimen/default_body2_line_height</item> + <item name="android:letterSpacing">@dimen/default_body2_letter_spacing</item> + </style> + + <style name="Widget.Template.Text.Body3"> + <item name="android:textAppearance">@style/TextAppearance.Template.Body3</item> + <item name="android:lineHeight" tools:ignore="NewApi">@dimen/default_body3_line_height</item> + <item name="android:letterSpacing">@dimen/default_body3_letter_spacing</item> + </style> + + <style name="Widget.Template.Text.Body3.Medium"> + <item name="android:textAppearance">@style/TextAppearance.Template.Body3.Medium</item> + </style> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/styles_default.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/styles_default.xml new file mode 100644 index 0000000..3595ae0 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/styles_default.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources xmlns:tools="http://schemas.android.com/tools"> + + <!-- Typography spec: + https://designguidelines.withgoogle.com/automotive-os-apps/visual-foundations/typography.html#typography-scale-grid-references --> + <style name="TextAppearance.Design" parent="TextAppearance.CarUi"> + <item name="android:textStyle">normal</item> + <item name="android:textColor">@color/default_text_color</item> + </style> + + <style name="TextAppearance.Design.Body1" parent="TextAppearance.CarUi.Body1"/> + + <style name="TextAppearance.Design.Body2" parent="TextAppearance.CarUi.Body2"/> + + <style name="TextAppearance.Design.Body3" parent="TextAppearance.CarUi.Body3"/> + + <style name="TextAppearance.Design.Body3.Medium" parent="TextAppearance.Design.Body3"> + <item name="android:fontFamily">sans-serif-medium</item> + </style> + + <style name="TextAppearance.Design.Display1" parent="TextAppearance.CarUi.Body1"> + <item name="android:textSize">@dimen/default_display1_text_size</item> + </style> + + <style name="TextAppearance.Design.Display2" parent="TextAppearance.CarUi.Body1"> + <item name="android:textSize">@dimen/default_display2_text_size</item> + </style> + + <style name="TextAppearance.Design.Display2.Medium" parent="TextAppearance.Design.Display2"> + <item name="android:fontFamily">sans-serif-medium</item> + </style> + + <style name="TextAppearance.Design.Display3" parent="TextAppearance.CarUi.Body1"> + <item name="android:textSize">@dimen/default_display3_text_size</item> + </style> + +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/themes.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/themes.xml new file mode 100644 index 0000000..16ae280 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/res/values/themes.xml @@ -0,0 +1,344 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- The template app theme. + + <p>This theme must be set on the root template view which is the parent of all the + template layouts and their child views. + + <p>Styles used by this theme are named using the convention Type.Template.Etc + (for example {@code TextAppearance.Template.RowTitle}).--> + + <style name="Theme.Template" parent="Theme.CarUi"> + + <!-- Common. --> + <item name="templateAdaptiveWidthFraction">@dimen/template_adaptive_width_fraction</item> + <!-- Margin between buttons placed side by side. --> + <item name="templateControlToControlSpacingHorizontal">@dimen/car_app_ui_control_to_control_spacing_horizontal</item> + <!-- Button height. --> + <item name="templateButtonHeight">@dimen/car_app_ui_button_height</item> + <!-- Corner radius used across the UI except for the buttons. --> + <item name="templateCornerRadius">@dimen/car_app_ui_corner_radius</item> + <!-- Corner radius used for the buttons. --> + <item name="templateButtonCornerRadius">@dimen/car_app_ui_button_corner_radius</item> + + <!-- Standard colors. --> + <item name="templateStandardBlue">@color/car_app_ui_standard_blue</item> + <item name="templateStandardRed">@color/car_app_ui_standard_red</item> + <item name="templateStandardGreen">@color/car_app_ui_standard_green</item> + <item name="templateStandardYellow">@color/car_app_ui_standard_yellow</item> + + <!-- Images. --> + <item name="templateLargeImageSize">@dimen/template_large_image_size</item> + <item name="templateLargeImageSizeMin">@dimen/template_large_image_size_min</item> + <item name="templateLargeImageSizeMax">@dimen/template_large_image_size_max</item> + + <!-- Sign In Template. --> + <item name="templateSignInContainerStyle">@style/Widget.Template.SignIn.Container</item> + <item name="templateSignInMethodViewMaxWidth">@dimen/template_sign_in_method_view_max_width</item> + <item name="templateSignInInstructionTextStyle">@style/Widget.Template.SignIn.InstructionText</item> + <item name="templateSignInProviderSignInButtonStyle">@style/Widget.Template.SignIn.ProviderSignInbutton</item> + <item name="templateSignInPinTextStyle">@style/Widget.Template.SignIn.PinText</item> + <item name="templateSignInPinBackgroundColor">@color/template_read_only_text_background_color</item> + <item name="templateSignInPinCornerRadius">?templateCornerRadius</item> + <item name="templateSignInPinBackground">@drawable/pin_sign_in_view_background</item> + <item name="templateSignInPinPadding">@dimen/template_read_only_text_padding</item> + <item name="templateSignInQRCodeImageWidth">@dimen/template_sign_in_qr_code_image_width</item> + <item name="templateSignInAdditionalTextStyle">@style/Widget.Template.SignIn.AdditionalText</item> + <item name="templateSignInErrorMessageStyle">@style/Widget.Template.SignIn.ErrorMessage</item> + <item name="templateSignInInputViewStyle">@style/Widget.CarAppUi.InputView</item> + + <!-- Hyperlink Text --> + <item name="templateHyperlinkTextColor">@color/car_app_ui_hyperlink_text_color</item> + + <!-- Map markers. --> + <item name="templateMapMarkerAppearance">@style/MarkerAppearance.Template.Map</item> + <item name="templateListMarkerAppearance">@style/MarkerAppearance.Template.List</item> + + <!-- Loading spinners. --> + <item name="templateLoadingSpinnerStyle">@style/Widget.Template.Spinner</item> + <item name="templateLoadingSpinnerColor">@color/template_loading_spinner_color</item> + + <!-- Plain content containers. --> + <item name="templatePlainContentContainerStyle">@style/Widget.Template.Container.Plain</item> + <item name="templatePlainContentContainerWidth">@dimen/template_plain_content_container_width</item> + <item name="templatePlainContentLayoutGravity">@integer/template_plain_content_layout_gravity</item> + <item name="templatePlainContentGravity">@integer/template_plain_content_gravity</item> + <item name="templatePlainContentHorizontalPadding">@dimen/template_plain_content_horizontal_padding</item> + <item name="templatePlainContentBackgroundColor">@color/template_plain_content_background_color</item> + + <!-- Card content containers. --> + <item name="templateCardContentContainerStyle">@style/Widget.Template.Container.Card.Content</item> + <item name="templateCardRoutingContentContainerStyle">@style/Widget.Template.Container.Card.Content.Routing</item> + <item name="templateCardContentContainerDefaultWidth">@dimen/template_card_content_container_default_width</item> + <item name="templateCardContentContainerStartMargin">@dimen/template_card_content_container_start_margin</item> + <item name="templateCardContentContainerTopMargin">@dimen/template_card_content_container_top_margin</item> + <item name="templateCardContentContainerBottomMargin">@dimen/template_card_content_container_bottom_margin</item> + <item name="templateCardContentContainerMinHeight">@dimen/template_card_content_container_min_height</item> + + <!-- Content headers. --> + <item name="templateHeaderHeight">@dimen/template_header_height</item> + <item name="templateHeaderTextVerticalSpacing">@dimen/template_header_text_vertical_spacing</item> + <item name="templateHeaderTextStartSpacing">@dimen/template_header_text_horizontal_spacing</item> + <item name="templateHeaderTextEndSpacing">@dimen/template_header_text_horizontal_spacing</item> + <item name="templateHeaderTextStyle">@style/TextAppearance.CarAppUi.CardHeader</item> + <item name="templateHeaderTextNoIconStartSpacing">@dimen/template_header_text_no_icon_start_spacing</item> + <item name="templateHeaderButtonIconSize">@dimen/template_header_button_icon_size</item> + <item name="templateHeaderButtonIconTint">@color/template_icon_tint_color</item> + <item name="templateHeaderButtonContainerSize">@dimen/template_button_touch_target_size</item> + <item name="templateHeaderButtonStartSpacing">@dimen/template_header_button_start_spacing</item> + <item name="templateHeaderButtonBackground">@drawable/template_header_button_background</item> + <item name="templateHeaderBackgroundColor">@color/template_container_background</item> + + <!-- Dividers. --> + <item name="templateDividerThickness">@dimen/template_divider_thickness</item> + <item name="templateDividerColor">@color/default_gradient_white_24</item> + + <!-- Rows. --> + <item name="templateRippleColor">@color/template_ripple_color</item> + <item name="templateRippleSelectorColor">@color/template_ripple_selector_color</item> + <item name="templateRowBackgroundColor">@color/car_app_ui_row_background_color</item> + <item name="templateRowSelectedBackgroundColor">@color/default_gray_878</item> + <item name="templateRowImagePlaceholderColor">@color/default_gray_928</item> + <item name="templateRowBackgroundSimple">@drawable/row_background_simple</item> + <item name="templateRowBackgroundSectionalMiddle">@drawable/row_background_sectional_middle</item> + <item name="templateRowBackgroundSectionalTop">@drawable/row_background_sectional_top</item> + <item name="templateRowBackgroundSectionalBottom">@drawable/row_background_sectional_bottom</item> + <item name="templateRowBackgroundSectionalTopBottom">@drawable/row_background_sectional_top_bottom</item> + <item name="templateRowBackgroundSectionalBottomMargin">@dimen/template_row_background_sectional_bottom_margin</item> + <item name="templateRowImagePlaceholder">@drawable/row_image_placeholder</item> + <item name="templateRowMinHeight">@dimen/template_row_min_height</item> + <item name="templateRowIconSize">@dimen/template_row_icon_size</item> + <item name="templateRowImageSizeSmall">@dimen/template_row_image_size_small</item> + <item name="templateRowImageSizeLarge">@dimen/template_row_image_size_large</item> + <item name="templateRowMarkerMinSize">@dimen/template_row_marker_min_size</item> + <item name="templateRowMarkerLabelMargin">@dimen/template_row_marker_label_margin</item> + <item name="templateRowDefaultIconTint">@color/template_icon_tint_color</item> + <item name="templateRowHorizontalPadding">@dimen/template_row_horizontal_padding</item> + <item name="templateRowHorizontalHalfPadding">@dimen/template_row_horizontal_half_padding</item> + <item name="templateRowTextHorizontalPadding">@dimen/template_row_text_horizontal_padding</item> + <item name="templateRowTextHorizontalHalfPadding">@dimen/template_row_text_horizontal_half_padding</item> + <item name="templateHalfListBottomPadding">@dimen/template_half_list_bottom_padding</item> + <item name="templateHalfListPaddingVertical">@dimen/template_half_row_padding_vertical</item> + + <!-- Half rows. --> + <item name="templateHalfRowMinHeight">@dimen/car_app_ui_half_row_min_height</item> + <item name="templateHalfRowHorizontalPadding">@dimen/template_half_row_horizontal_padding</item> + <item name="templateHalfRowVerticalPadding">@dimen/template_half_row_vertical_padding</item> + <item name="templateHalfRowImageToTextSpacing">@dimen/template_half_row_image_to_text_margin</item> + <item name="templateHalfRowTextToTextSpacing">@dimen/template_half_row_text_to_text_margin</item> + <item name="templateHalfRowImageSize">@dimen/template_half_row_image_size</item> + + <!-- Full rows. --> + <item name="templateFullRowStartPadding">@dimen/template_full_row_start_padding</item> + <item name="templateFullRowEndPadding">@dimen/template_full_row_end_padding</item> + <item name="templateFullRowChevronIcon">@drawable/car_ui_preference_icon_chevron</item> + <item name="templateHalfRowChevronIcon">@drawable/car_ui_preference_icon_chevron</item> + <item name="templateFullRowChevronHeight">@dimen/template_full_row_chevron_size</item> + <item name="templateFullRowChevronWidth">@dimen/template_full_row_chevron_size</item> + + <!-- Note, these containers are sized to match the height of the title of the row + so that they appear vertically aligned at the center when both are top aligned. --> + <item name="templateRowSelectionContainerHeight">@dimen/default_body2_line_height</item> + + <!-- Text styles for list elements. --> + <item name="templateRowTitleStyle">@style/Widget.CarAppUi.RowTitle</item> + <item name="templateRowSecondaryTextStyle">@style/Widget.CarAppUi.RowSecondary</item> + <item name="templateRowSectionHeaderStyle">@style/Widget.CarAppUi.RowSectionHeader</item> + <item name="templateRowListEmptyTextStyle">@style/Widget.CarAppUi.RowListEmpty</item> + + <!-- The image dimensions (for PaneTemplate) in the row list template. --> + <item name="templateRowListToLargeImageRatio">@dimen/template_row_list_to_large_image_ratio</item> + <item name="templateRowListLargeImageContainerMaxWidth">@dimen/template_row_list_large_image_container_max_width</item> + <item name="templateRowListLargeImageAspectRatio">@dimen/template_row_list_large_image_aspect_ratio</item> + + <!-- Padding between the (PaneTemplate) image and row list --> + <item name="templateRowListAndImagePadding">@dimen/default_width_keyline_1</item> + + <!-- Grids. --> + <item name="templateGridStyle">@style/Widget.Template.Grid</item> + + <!-- Grid items. --> + <item name="templateGridItemImageBottomPadding">@dimen/template_grid_item_image_bottom_padding</item> + <item name="templateGridItemDefaultIconTint">@color/template_icon_tint_color</item> + <item name="templateGridItemTextContainerMaxWidth">@dimen/template_grid_item_text_container_max_width</item> + <item name="templateGridItemTextBottomPadding">@dimen/template_grid_item_text_bottom_padding</item> + <item name="templateGridItemHorizontalSpacing">@dimen/template_grid_item_horizontal_spacing</item> + <item name="templateGridItemVerticalSpacing">@dimen/template_grid_item_vertical_spacing</item> + <item name="templateGridItemsPerRow">@integer/template_grid_items_per_row</item> + <item name="templateGridEmptyTextStyle">@style/Widget.Template.GridEmpty</item> + <item name="templateGridItemBackground">@drawable/template_grid_item_background</item> + <item name="templateGridItemBackgroundColor">@color/car_app_ui_grid_item_background_color</item> + + <!-- Text styles for grid items. --> + <item name="templateGridItemTitleStyle">@style/Widget.Template.Text.GridItemTitle</item> + <item name="templateGridItemTextStyle">@style/Widget.Template.Text.GridItemText</item> + + <!-- Action buttons and FABs. --> + <item name="templateActionButtonMargin">?templateControlToControlSpacingHorizontal</item> + <item name="templateActionButtonStyle">@style/Widget.Template.ActionButton</item> + <item name="templateActionButtonTextStyle">@style/Widget.Template.Text.ActionButton</item> + <item name="templateActionButtonForeground">@drawable/action_button_focus_ring</item> + <item name="templateActionButtonDefaultBackgroundColor">@color/car_app_ui_action_button_default_background_color</item> + <item name="templateActionButtonPrimaryBackgroundColor">@color/car_app_ui_action_button_primary_background_color</item> + <item name="templateActionButtonBackground">@drawable/car_app_ui_action_button_background</item> + <item name="templateActionButtonHeight">?templateButtonHeight</item> + <item name="templateActionButtonTouchTargetSize">@dimen/template_button_touch_target_size</item> + <item name="templateActionButtonListButtonStretchHorizontal">@bool/car_app_ui_action_button_list_button_stretch_horizontal</item> + <item name="templateActionButtonListGravity">@integer/car_app_ui_action_button_list_gravity</item> + <item name="templateActionButtonListButtonContentAlignment">@integer/car_app_ui_action_button_list_button_content_alignment</item> + <item name="templateActionButtonListButtonMaxWidth">@dimen/car_app_ui_action_button_list_button_max_width</item> + <item name="templateActionButtonSideAlignmentSpacing">@dimen/car_app_ui_button_side_alignment_spacing</item> + <item name="templateActionButtonListRowVerticalSpacing">@dimen/template_action_button_list_row_vertical_spacing</item> + <item name="templateActionIconSize">@dimen/template_action_icon_size</item> + <item name="templateActionIconSizeMin">@dimen/template_action_icon_size_min</item> + <item name="templateActionIconSizeMax">@dimen/template_action_icon_size_max</item> + <item name="templateActionIconTextStartSpacing">@dimen/template_action_icon_text_start_spacing</item> + <item name="templateActionIconTextEndSpacing">@dimen/template_action_icon_text_end_spacing</item> + <item name="templateActionIconToTextSpacing">@dimen/template_action_icon_to_text_spacing</item> + <item name="templateActionTextHorizontalSpacing">@dimen/template_action_text_horizontal_spacing</item> + <item name="templateActionWithTextMinWidth">@dimen/template_action_with_text_min_width</item> + <item name="templateActionButtonUseOemColors">@bool/car_app_ui_is_action_color_overridden</item> + <item name="templateActionButtonPrimaryHorizontalOrder">@integer/car_app_ui_action_button_primary_horizontal_order</item> + <!-- Min width common to buttons and FABs. It is the same as the height of + the action button so that the button appears with 1:1 aspect ratio when it has just + an icon inside. --> + <item name="templateActionWithoutTextMinWidth">?templateButtonHeight</item> + <item name="templateActionDefaultIconTint">@color/template_white</item> + <item name="templateActionButtonTextMaxEmsNoIcon">@integer/template_action_button_text_max_ems_no_icon</item> + <item name="templateActionButtonTextMaxEmsWithIcon">@integer/template_action_button_text_max_ems_with_icon</item> + <item name="templateFabTextMaxEmsNoIcon">@integer/template_fab_text_max_ems_no_icon</item> + <item name="templateFabTextMaxEmsWithIcon">@integer/template_fab_text_max_ems_with_icon</item> + <item name="templateActionButtonSecondaryBorderWidth">@dimen/template_action_button_secondary_border_width</item> + <item name="templateActionButtonSecondaryBorderColor">@color/template_white_46</item> + <item name="templateActionStripButtonMargin">?templateControlToControlSpacingHorizontal</item> + <item name="templateActionStripPadding">@dimen/template_action_strip_padding</item> + + <item name="templateActionStripButtonBackgroundColor">@color/template_black</item> + <item name="templateActionStripFabAppearance">@style/FabAppearance.Template.Fab</item> + <item name="templateActionStripFullTemplateFabAppearance">@style/FabAppearance.Template.FullTemplate</item> + + <!-- FAB background color. + They point to the same resource, which is set to a different value between light and dark modes. --> + <item name="templateActionStripFabBackgroundColorLight">@color/template_action_strip_fab_background_color</item> + <item name="templateActionStripFabBackgroundColorDark">@color/template_action_strip_fab_background_color</item> + + <!-- Toggles and radio buttons. --> + <item name="templateToggleWidth">@dimen/template_toggle_width</item> + <item name="templateToggleHeight">@dimen/template_toggle_height</item> + <item name="templateToggleInactiveTrackColor">@color/template_toggle_inactive_track</item> + <item name="templateToggleInactiveThumbColor">@color/template_toggle_inactive_thumb</item> + <item name="templateToggleActiveTrackColor">@color/template_toggle_active_track</item> + <item name="templateToggleActiveThumbColor">@color/template_toggle_active_thumb</item> + <item name="templateRadioButtonSize">@dimen/template_radio_button_size</item> + + <!-- Clickable spans. --> + <item name="templateClickableSpanHighlightForegroundColor">@color/template_black</item> + <item name="templateClickableSpanHighlightBackgroundColor">@color/template_focus_ring_color_selector</item> + + <!-- Full screen message --> + <item name="templateMessageDefaultIconTint">@color/template_white</item> + <item name="templateMessageTitleTextStyle">@style/Widget.Template.Text.Message</item> + <item name="templateMessageTitleTopSpacing">@dimen/template_message_title_top_spacing</item> + <item name="templateMessageButtonsTopSpacing">@dimen/template_message_buttons_top_spacing</item> + <item name="templateStickyButtonsVerticalSpacing">@dimen/template_sticky_buttons_vertical_spacing</item> + + <item name="templateMessageLongTextStyle">@style/Widget.Template.Text.LongMessage</item> + <item name="templateMessageDebugTextStyle">@style/Widget.Template.Debug</item> + <item name="templateDebugMessageBackgroundColor">@color/template_gray_900</item> + + <!-- Focus. --> + <item name="templateFocusAccentColor">@color/template_focus_ring_color_selector</item> + <item name="templateFocusNoContentAccentColor">@color/default_focus_no_content</item> + <item name="templateFocusRingColor">@color/default_focus_blue</item> + <item name="templateFocusRingNoAccentColor">@color/default_focus_no_content</item> + + <!-- EditText. --> + <item name="templateEditTextStyle">@style/Widget.CarAppUi.EditText</item> + <item name="templateEditTextActiveColor">@color/template_edit_text_active_color</item> + <item name="templateEditTextEnabledColor">@color/template_edit_text_enabled_color</item> + <item name="templateEditTextErrorColor">@color/template_edit_text_error_color</item> + <item name="templateEditTextDisabledColor">@color/template_edit_text_disabled_color</item> + <item name="templateEditTextErrorVerticalSpacing">@dimen/car_app_ui_edit_text_error_vertical_spacing</item> + <item name="templateEditTextErrorHorizontalSpacing">@dimen/car_app_ui_edit_text_error_horizontal_spacing</item> + + <!-- Search bar. --> + <item name="templateSearchBarMaxWidth">@dimen/template_search_bar_max_width</item> + <item name="templateSearchBarIcon">@drawable/search_bar_icon</item> + + <!-- Routing --> + <item name="templateRoutingStepsCardIconToDistanceSpacingHorizontal">@dimen/template_steps_card_image_to_text_spacing_horizontal</item> + <item name="templateRoutingImageSpanRatio">@dimen/template_routing_image_span_ratio</item> + <item name="templateRoutingImageSpanBody2MaxHeight">@dimen/template_routing_image_span_body2_max_height</item> + <item name="templateRoutingImageSpanBody3MaxHeight">@dimen/template_routing_image_span_body3_max_height</item> + <item name="templateNavCardLargeTextSize" format="dimension">@dimen/car_app_ui_nav_card_large_text_size</item> + <item name="templateNavCardXLargeTextSize" format="dimension">@dimen/car_app_ui_nav_card_xlarge_text_size</item> + <item name="templateNavCardLargeImageSize" format="dimension">@dimen/template_nav_card_large_image_size</item> + <item name="templateNavCardLargeImageSizeMin" format="dimension">@dimen/template_nav_card_large_image_size_min</item> + <item name="templateNavCardLargeImageSizeMax" format="dimension">@dimen/template_nav_card_large_image_size_max</item> + <item name="templateNavCardSmallImageSize" format="dimension">@dimen/template_nav_card_small_image_size</item> + <item name="templateNavCardSmallImageSizeMin" format="dimension">@dimen/template_nav_card_small_image_size_min</item> + <item name="templateNavCardSmallImageSizeMax" format="dimension">@dimen/template_nav_card_small_image_size_max</item> + <item name="templateNavCardFallbackContentColor">@color/default_white</item> + <item name="templateRoutingDistanceStyle">@style/Widget.Template.Routing.Distance</item> + <item name="templateRoutingDescriptionStyle">@style/Widget.Template.Routing.Description</item> + <item name="templateRoutingCompactDescriptionStyle">@style/Widget.Template.Routing.CompactDescription</item> + <item name="templateRoutingTravelEstimateStyle">@style/Widget.Template.Routing.TravelEstimate</item> + <item name="templateRoutingLanesImageContainerHeight">@dimen/template_routing_lanes_image_container_height</item> + <item name="templateRoutingLanesImageContainerVerticalPadding">@dimen/template_padding_0</item> + <item name="templateRoutingLanesImageContainerHorizontalPadding">@dimen/template_padding_2</item> + <item name="templateRoutingLanesImageBackgroundColor">@color/template_white_16</item> + <item name="templateRoutingJunctionImageBackgroundColor">@color/template_white_16</item> + <item name="templateRoutingMessagePrimaryStyle">@style/Widget.Template.Routing.MessagePrimary</item> + <item name="templateRoutingMessageSecondaryStyle">@style/Widget.Template.Routing.MessageSecondary</item> + <item name="templateRoutingMessageInnerPaddingHorizontal">@dimen/template_padding_3</item> + <item name="templateRoutingMessageInnerPaddingVertical">@dimen/template_padding_1</item> + <item name="templateNavCardPaddingHorizontal">@dimen/template_nav_card_padding_horizontal</item> + <item name="templateNavCardPaddingVertical">@dimen/template_nav_card_padding_vertical</item> + <item name="templateNavCardSmallPaddingVertical">@dimen/template_nav_card_small_padding_vertical</item> + <item name="templateRoutingStepsCardContentContainerMinWidth">@dimen/template_steps_card_content_container_min_width</item> + <item name="templateRoutingStepsCardContentContainerMinHeight">@dimen/template_steps_card_content_container_min_height</item> + <item name="templateRoutingDividerColor">@color/template_white_16</item> + + <!-- Status bar gradient background start and end colors. --> + <item name="templateStatusBarStartColor">@android:color/transparent</item> + <item name="templateStatusBarEndColor">@color/template_status_bar_end_color</item> + <item name="templateStatusBarMinimumTopPadding">@dimen/template_status_bar_minimum_top_padding</item> + + <!-- No content view. --> + <item name="templateNoContentFocusCornerRadius">@dimen/template_no_content_view_focus_corner_radius</item> + + <!-- Animation. --> + <item name="templateUpdateAnimationDurationMilliseconds">@integer/template_update_animation_duration_millis</item> + + <!-- This is necessary so that the floating elements such as the action + strip don't get their shadows clipped. --> + <item name="android:clipChildren">false</item> + <item name="android:clipToPadding">false</item> + + <!-- The max number of rows in a list view. --> + <item name="templateListMaxLength">@integer/car_app_ui_list_max_length</item> + + <!-- The max number of grid items in a grid view. --> + <item name="templateGridMaxLength">@integer/car_app_ui_grid_max_length</item> + + <item name="templateSendNavStateToSystem">@bool/send_navstates_to_system</item> + + </style> + +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/AbstractHeaderView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/AbstractHeaderView.java new file mode 100644 index 0000000..9babeca --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/AbstractHeaderView.java @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.graphics.drawable.Drawable; +import android.util.Log; +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.CarColor; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.OnClickDelegate; +import com.android.car.libraries.apphost.common.CarAppError; +import com.android.car.libraries.apphost.common.CommonUtils; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.libraries.apphost.template.view.model.ActionStripWrapper; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.ui.toolbar.MenuItem; +import com.android.car.ui.toolbar.Toolbar; +import com.android.car.ui.toolbar.Toolbar.NavButtonMode; +import com.android.car.ui.toolbar.ToolbarController; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** A view that displays the header for the templates. */ +public abstract class AbstractHeaderView { + protected final TemplateContext mTemplateContext; + protected final ToolbarController mToolbarController; + + // TODO(b/183853224): Replace with equivalent ToolbarController, once is available + @SuppressWarnings("deprecation") + private final Toolbar.OnBackListener mBackListener = + new Toolbar.OnBackListener() { + @Override + public boolean onBack() { + if (mTemplateContext != null) { + mTemplateContext.getBackPressedHandler().onBackPressed(); + } + return true; + } + }; + + protected AbstractHeaderView( + TemplateContext templateContext, ToolbarController toolbarController) { + mTemplateContext = templateContext; + mToolbarController = toolbarController; + } + + @VisibleForTesting + protected ToolbarController getToolbarController() { + return mToolbarController; + } + + /** Updates the header action */ + protected void setAction(@Nullable Action action) { + if (action != null && action.getType() == Action.TYPE_BACK) { + mToolbarController.registerOnBackListener(mBackListener); + mToolbarController.setNavButtonMode(NavButtonMode.BACK); + } else { + mToolbarController.unregisterOnBackListener(mBackListener); + mToolbarController.setNavButtonMode(NavButtonMode.DISABLED); + } + + if (action != null && action.getType() == Action.TYPE_APP_ICON) { + mToolbarController.setLogo(mTemplateContext.getCarAppPackageInfo().getRoundAppIcon()); + } else { + mToolbarController.setLogo(0); + } + } + + /** Updates the [ActionStrip] associated with this toolbar */ + public void setActionStrip(@Nullable ActionStrip actionStrip, ActionsConstraints constraints) { + validateActionStrip(actionStrip, constraints); + if (actionStrip == null) { + mToolbarController.setMenuItems(null); + } else { + List<MenuItem> menuItems = createMenuItems(actionStrip, mTemplateContext); + mToolbarController.setMenuItems(menuItems); + } + } + + /** Adds a toggle to this toolbar */ + public void addToggle(@Nullable Drawable icon, @Nullable Consumer<Boolean> onClickListener) { + List<MenuItem> menuItemList = new ArrayList<>(mToolbarController.getMenuItems()); + if (icon != null) { + menuItemList.add(new MenuItem.Builder(mTemplateContext).setIcon(icon).build()); + } + menuItemList.add( + new MenuItem.Builder(mTemplateContext) + .setCheckable() + .setOnClickListener( + item -> { + if (onClickListener != null) { + onClickListener.accept(item.isChecked()); + } + }) + .build()); + mToolbarController.setMenuItems(menuItemList); + } + + /** Ensure the model satisfies the input constraints. */ + private void validateActionStrip( + @Nullable ActionStrip actionStrip, ActionsConstraints constraints) { + ActionStripWrapper actionStripWrapper = + actionStrip == null ? null : new ActionStripWrapper.Builder(actionStrip).build(); + try { + ActionStripUtils.validateRequiredTypes(actionStripWrapper, constraints); + } catch (ActionStripUtils.ValidationException exception) { + mTemplateContext + .getErrorHandler() + .showError( + CarAppError.builder(mTemplateContext.getCarAppPackageInfo().getComponentName()) + .setCause(exception) + .build()); + } + } + + /** Converts an [ActionStrip] to a list of [MenuItem]s. */ + protected static List<MenuItem> createMenuItems( + ActionStrip actionStrip, TemplateContext templateContext) { + List<MenuItem> menuItems = new ArrayList<>(); + for (Object action : actionStrip.getActions()) { + if (action instanceof Action) { + MenuItem menuItem = createMenuItem((Action) action, templateContext); + menuItems.add(menuItem); + } else { + Log.e(LogTags.TEMPLATE, "Action is not supported: " + action); + } + } + return menuItems; + } + + /** Converts an {@link Action} to a {@link MenuItem}. */ + private static MenuItem createMenuItem(Action action, TemplateContext templateContext) { + CarIcon carIcon = action.getIcon(); + boolean isTinted = carIcon == null || carIcon.getType() != CarIcon.TYPE_APP_ICON; + MenuItem.Builder menuItemBuilder = + new MenuItem.Builder(templateContext) + .setPrimary(true) + .setEnabled(true) + .setTinted(isTinted) + .setShowIconAndTitle(true) + .setTitle(CarTextUtils.toCharSequenceOrEmpty(templateContext, action.getTitle())); + OnClickDelegate onClickDelegate = action.getOnClickDelegate(); + if (onClickDelegate != null) { + menuItemBuilder.setOnClickListener( + item -> CommonUtils.dispatchClick(templateContext, onClickDelegate)); + } + + MenuItem menuItem = menuItemBuilder.build(); + + int menuItemIconSize = + (int) + templateContext + .getResources() + .getDimension(com.android.car.ui.R.dimen.car_ui_toolbar_menu_item_icon_size); + carIcon = ImageUtils.getIconFromAction(action); + if (carIcon != null) { + ImageViewParams imageViewParams; + CarColor tintColor = carIcon.getTint(); + if (tintColor != null && tintColor.getColor() != 0) { + imageViewParams = + ImageViewParams.builder() + .setDefaultTint(tintColor.getColor()) + .setForceTinting(true) + .build(); + } else { + imageViewParams = ImageViewParams.DEFAULT; + } + + ImageUtils.setImageTargetSrc( + templateContext, + carIcon, + menuItem::setIcon, + imageViewParams, + menuItemIconSize, + menuItemIconSize); + } + return menuItem; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonListView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonListView.java new file mode 100644 index 0000000..96b4c15 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonListView.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static com.android.car.libraries.apphost.common.CarHostConfig.PRIMARY_ACTION_HORIZONTAL_ORDER_LEFT; +import static com.android.car.libraries.apphost.common.CarHostConfig.PRIMARY_ACTION_HORIZONTAL_ORDER_NOT_SET; +import static com.android.car.libraries.apphost.common.CarHostConfig.PRIMARY_ACTION_HORIZONTAL_ORDER_RIGHT; +import static java.lang.Math.min; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarColor; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import com.android.car.libraries.apphost.common.CarColorUtils; +import com.android.car.libraries.apphost.common.CarHostConfig.PrimaryActionOrdering; +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.view.common.ActionButtonListParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonView.ActionFlag; +import java.util.ArrayList; +import java.util.List; + +/** Displays a list of {@link Action}s as buttons in a single horizontal layout. */ +public class ActionButtonListView extends LinearLayout { + + public enum Gravity { + /* Indicates that action button list can be rendered within the content. */ + CENTER, + + /* Indicates that action button list should be pinned to the bottom of the content. */ + BOTTOM + } + + /** A flag that indicates whether the buttons stretch horizontally to fill the available space. */ + private final boolean mButtonsStretch; + + /** + * The maximum button width. + * + * <p>This limit is only applied when {@link #mButtonsStretch} is set to {@code true}. + */ + private final int mButtonMaxWidth; + + /** The horizontal spacing between the button list and the parent view. */ + private final int mHorizontalSpacing; + + /** Minimum touch area for each button in this list */ + private final int mMinTouchTargetSize; + + @ColorInt private final int mDefaultButtonBackgroundColor; + + public ActionButtonListView(Context context) { + this(context, null); + } + + public ActionButtonListView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ActionButtonListView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public ActionButtonListView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateActionButtonListButtonStretchHorizontal, + R.attr.templateActionButtonListButtonMaxWidth, + R.attr.templatePlainContentHorizontalPadding, + R.attr.templateActionButtonTouchTargetSize, + R.attr.templateActionButtonDefaultBackgroundColor, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mButtonsStretch = ta.getBoolean(0, false); + mButtonMaxWidth = ta.getDimensionPixelSize(1, 0); + mHorizontalSpacing = ta.getDimensionPixelSize(2, 0); + mMinTouchTargetSize = ta.getDimensionPixelSize(3, 0); + mDefaultButtonBackgroundColor = ta.getColor(4, 0); + ta.recycle(); + } + + /** Returns the {@link ActionButtonView} for testing. */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public ActionButtonView getActionButtonView(int index) { + int maxIndex = getChildCount() - 1; + if (index > maxIndex || index < 0) { + throw new IndexOutOfBoundsException( + "Action index is not within bounds of [0, " + maxIndex + "]"); + } + + View child = getChildAt(index); + if (child instanceof ActionButtonView) { + return (ActionButtonView) child; + } + throw new IllegalStateException( + "Found unexpected type of view in action list: " + child.getClass()); + } + + /** Returns the size of the list for testing. */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public int size() { + return getChildCount(); + } + + /** + * Sets the {@link Action}s that will be mapped into buttons. + * + * @see ActionFlag + */ + public void setActionList( + TemplateContext templateContext, List<Action> actionList, ActionButtonListParams params) { + LayoutInflater inflater = LayoutInflater.from(getContext()); + + removeAllViews(); + + if (actionList == null || actionList.isEmpty()) { + this.setVisibility(View.GONE); + return; + } + + setVisibility(View.VISIBLE); + + @StyleableRes final int[] themeAttrs = {R.attr.templateActionButtonMargin}; + TypedArray ta = templateContext.obtainStyledAttributes(themeAttrs); + int actionMargin = ta.getDimensionPixelOffset(0, 0); + ta.recycle(); + + int maxActions = params.getMaxActions(); + if (actionList.size() > maxActions) { + L.w( + LogTags.TEMPLATE, + "The number of actions exceeds the maximum allowed action count, skipping later actions"); + actionList = actionList.subList(0, maxActions); + } + + if (params.allowOemReordering()) { + int primaryActionOrder = templateContext.getCarHostConfig().getPrimaryActionOrder(); + actionList = reorderActionList(actionList, primaryActionOrder); + } + + // Calculate the stretching button width by subtracting the margins and paddings from the + // screen width, and dividing the remaining width by the button count. Then cap the + // resulting width at the button max width. + int screenWidth = getResources().getDisplayMetrics().widthPixels; + int buttonCount = actionList.size(); + int stretchingButtonWidth = + min( + (screenWidth - actionMargin * (buttonCount - 1) - 2 * mHorizontalSpacing) / buttonCount, + mButtonMaxWidth); + + ViewGroup touchContainer = (ViewGroup) getParent(); + touchContainer.setTouchDelegate(null); + + boolean allowAppColor = + getAllowAppColor(templateContext, actionList, params, mDefaultButtonBackgroundColor); + params = ActionButtonListParams.builder(params).setAllowAppColor(allowAppColor).build(); + + int count = 0; + for (Action action : actionList) { + View view = inflater.inflate(R.layout.action_button_view, this, false); + ((ActionButtonView) view).setAction(templateContext, action, params); + + LayoutParams layoutParams = (LayoutParams) view.getLayoutParams(); + if (count > 0) { + layoutParams.setMarginStart(actionMargin); + } + + if (mButtonsStretch) { + layoutParams.width = stretchingButtonWidth; + } + + addView(view, layoutParams); + ViewUtils.setMinTapTarget(touchContainer, view, mMinTouchTargetSize); + ++count; + } + } + + /** Returns whether app-provided colors can be applied to the buttons. */ + private static boolean getAllowAppColor( + TemplateContext templateContext, + List<Action> actionList, + ActionButtonListParams params, + @ColorInt int defaultButtonBackgroundColor) { + if (templateContext.getCarHostConfig().isButtonColorOverriddenByOEM() + && params.allowOemColorOverride()) { + // OEM overrides app colors + return false; + } + + // Allow app colors only if the contrast check passes + return checkColorContrast( + templateContext, actionList, params.getSurroundingColor(), defaultButtonBackgroundColor); + } + + /** + * Checks the color contrast between contents of the given action list and the background color. + */ + private static boolean checkColorContrast( + TemplateContext templateContext, + List<Action> actionList, + @ColorInt int surroundingColor, + @ColorInt int defaultButtonBackgroundColor) { + for (Action action : actionList) { + // Check if the background color has enough contrast against the surrounding color. + CarColor backgroundCarColor = action.getBackgroundColor(); + if (backgroundCarColor != null) { + if (!CarColorUtils.checkColorContrast( + templateContext, backgroundCarColor, surroundingColor)) { + return false; + } + } + + // Check if the text color has enough contrast against the background color. + @ColorInt + int backgroundColor = + ActionButtonViewUtils.getBackgroundColor( + templateContext, + action, + /* surroundingColor= */ surroundingColor, + /* defaultBackgroundColor= */ defaultButtonBackgroundColor); + CarText title = action.getTitle(); + if (title != null) { + if (!CarTextUtils.checkColorContrast(templateContext, title, backgroundColor)) { + return false; + } + } + + // Check if the icon tint has enough contrast against the background color. + CarIcon icon = action.getIcon(); + if (icon != null) { + CarColor tint = icon.getTint(); + if (tint != null) { + if (!CarColorUtils.checkColorContrast(templateContext, tint, backgroundColor)) { + return false; + } + } + } + } + return true; + } + + /** {@link ActionButtonView}s will carry out the action when clicked on without toast. */ + public void enableActionButtons() { + for (int index = 0; index < getChildCount(); index++) { + View child = getChildAt(index); + if (child instanceof ActionButtonView) { + ActionButtonView button = (ActionButtonView) child; + button.enableActionButton(); + } + } + } + + /** + * {@link ActionButtonView}s will show a toast with given message instead of carrying out the + * action when clicked on. + */ + public void disableActionButtons(String disabledToastMessage) { + for (int index = 0; index < getChildCount(); index++) { + View child = getChildAt(index); + if (child instanceof ActionButtonView) { + ActionButtonView button = (ActionButtonView) child; + button.disableActionButton(disabledToastMessage); + } + } + } + + private List<Action> reorderActionList( + List<Action> actionList, @PrimaryActionOrdering int primaryActionOrder) { + ArrayList<Action> mutableActionList = new ArrayList<>(actionList); + if (primaryActionOrder == PRIMARY_ACTION_HORIZONTAL_ORDER_NOT_SET) { + return actionList; + } + int indexOfPrimaryAction = 0; + @Nullable Action primaryAction = null; + for (Action action : mutableActionList) { + if (ActionButtonViewUtils.isPrimaryAction(action)) { + primaryAction = action; + break; + } + indexOfPrimaryAction++; + } + if (primaryAction != null) { + mutableActionList.remove(indexOfPrimaryAction); + if (primaryActionOrder == PRIMARY_ACTION_HORIZONTAL_ORDER_LEFT) { + mutableActionList.add(0, primaryAction); + } else if (primaryActionOrder == PRIMARY_ACTION_HORIZONTAL_ORDER_RIGHT) { + mutableActionList.add(primaryAction); + } + } + return mutableActionList; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonView.java new file mode 100644 index 0000000..fdeb929 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonView.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static androidx.car.app.model.Action.TYPE_BACK; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.Toast; +import androidx.annotation.ColorInt; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarColor; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.OnClickDelegate; +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.logging.TelemetryEvent.UiAction; +import com.android.car.libraries.apphost.view.common.ActionButtonListParams; +import com.android.car.libraries.apphost.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Displays an {@link Action} as a button. */ +public class ActionButtonView extends FrameLayout { + private static final int[] BUTTON_PRIMARY = + new int[] {R.attr.type_primary}; + private static final int[] BUTTON_CUSTOM = + new int[] {R.attr.type_custom}; + private static final int[] BUTTON_CUSTOM_PRIMARY = + new int[] { + R.attr.type_custom, + R.attr.type_primary + }; + + @IntDef( + flag = true, + value = { + FLAG_SUPPORT_REORDERING_BY_OEM, + FLAG_SUPPORT_CUSTOMIZED_COLOR_BY_OEM, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ActionFlag {} + + public static final int FLAG_SUPPORT_REORDERING_BY_OEM = 1 << 0; + public static final int FLAG_SUPPORT_CUSTOMIZED_COLOR_BY_OEM = 1 << 1; + + @ColorInt private final int mDefaultBackgroundColor; + @ColorInt private final int mDefaultIconTint; + private final int mMinWidthWithText; + private final int mMinWidthWithoutText; + private final int mSideAlignmentSpacing; + private final int mCustomMaxEms; + private boolean mIsPrimary; + private boolean mIsCustom; + private boolean mIsEnabled; + private String mDisabledToastMessage; + + /** + * The content alignment. + * + * <p>The possible values are: + * + * <ul> + * <li>0: center (default) + * <li>1: left + * <li>2: right + * </ul> + */ + private final int mContentAlignment; + + /** A flag that indicates whether the buttons stretch horizontally to fill the available space. */ + private final boolean mButtonsStretch; + + public ActionButtonView(Context context) { + this(context, null); + } + + public ActionButtonView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ActionButtonView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public ActionButtonView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateActionButtonDefaultBackgroundColor, + R.attr.templateActionDefaultIconTint, + R.attr.templateActionWithTextMinWidth, + R.attr.templateActionWithoutTextMinWidth, + R.attr.templateActionButtonSideAlignmentSpacing, + R.attr.templateActionButtonListButtonContentAlignment, + R.attr.templateActionButtonListButtonStretchHorizontal, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mDefaultBackgroundColor = ta.getColor(0, 0); + mDefaultIconTint = ta.getColor(1, 0); + mMinWidthWithText = ta.getDimensionPixelSize(2, 0); + mMinWidthWithoutText = ta.getDimensionPixelSize(3, 0); + mSideAlignmentSpacing = ta.getDimensionPixelSize(4, 0); + mContentAlignment = ta.getInteger(5, 0); + mButtonsStretch = ta.getBoolean(6, false); + ta.recycle(); + + // TODO(b/184195457): remove the custom maxEms limit when we remove the limit for all + ta = + context.obtainStyledAttributes( + attrs, R.styleable.ActionButtonView, defStyleAttr, defStyleRes); + mCustomMaxEms = ta.getInt(R.styleable.ActionButtonView_textMaxEms, 0); + ta.recycle(); + + mIsEnabled = true; + } + + /** Returns the {@link android.view.View} title for testing. */ + @Nullable + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public String getTitle() { + CarUiTextView carUiTextView = findViewById(R.id.action_text); + if (carUiTextView == null) { + return null; + } + return carUiTextView.getText().toString(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + } + + /** Updates the view from the {@link Action} model. */ + public ActionButtonView setAction( + TemplateContext templateContext, Action action, ActionButtonListParams params) { + L.v(LogTags.TEMPLATE, "Setting action view with action: %s", action); + + removeAllViews(); + + final boolean allowAppColor = params.allowAppColor(); + + // Set the background color + final CarColor color = action.getBackgroundColor(); + @ColorInt int appBackgroundColor = mDefaultBackgroundColor; + if (color != null && allowAppColor) { + appBackgroundColor = + ActionButtonViewUtils.getBackgroundColor( + templateContext, + action, + /* surroundingColor= */ params.getSurroundingColor(), + /* defaultBackgroundColor= */ mDefaultBackgroundColor); + } + + final boolean useAppColors = appBackgroundColor != mDefaultBackgroundColor; + if (useAppColors) { + // Set the background as tint to not override the round-corner drawable with ripple effects. + setBackgroundTintList(ColorStateList.valueOf(appBackgroundColor)); + } + + boolean useOemColor = + templateContext.getCarHostConfig().isButtonColorOverriddenByOEM() + && params.allowOemColorOverride(); + updateState( + /* isCustom= */ useAppColors, + /* isPrimary= */ useOemColor && ActionButtonViewUtils.isPrimaryAction(action)); + + // Check if the title's color span has enough contrast against the background color + final CarColorConstraints textColorConstraints; + CarText titleText = action.getTitle(); + if (allowAppColor + && titleText != null + && CarTextUtils.checkColorContrast(templateContext, titleText, appBackgroundColor)) { + textColorConstraints = CarColorConstraints.UNCONSTRAINED; + } else { + textColorConstraints = CarColorConstraints.NO_COLOR; + } + + final CarTextParams carTextParams = + CarTextParams.builder() + .setColorSpanConstraints(textColorConstraints) + .setBackgroundColor(appBackgroundColor) + .build(); + CharSequence title = + CarTextUtils.toCharSequenceOrEmpty(templateContext, titleText, carTextParams); + CarIcon icon = ImageUtils.getIconFromAction(action); + + boolean hasTitle = title.length() > 0; + + setMinimumWidth(hasTitle ? mMinWidthWithText : mMinWidthWithoutText); + + LayoutInflater inflater = LayoutInflater.from(getContext()); + if (icon != null && hasTitle) { + inflater.inflate(R.layout.action_button_view_icon_text, this); + } else if (hasTitle) { + inflater.inflate(R.layout.action_button_view_text, this); + } else { + inflater.inflate(R.layout.action_button_view_icon, this); + } + + if (icon != null) { + ImageView iconView = findViewById(R.id.action_icon); + ImageViewParams imageViewParams = + ImageViewParams.builder() + .setDefaultTint(mDefaultIconTint) + .setForceTinting(true) + .setIgnoreAppTint(!allowAppColor) + .setBackgroundColor(appBackgroundColor) + .build(); + ImageUtils.setImageSrc(templateContext, icon, iconView, imageViewParams); + } + + if (hasTitle) { + CarUiTextView carUiTextView = findViewById(R.id.action_text); + if (mCustomMaxEms > 0) { + carUiTextView.setMaxEms(mCustomMaxEms); + } else if (mButtonsStretch) { + // If max EMS is not set and the button stretches, allow the buttons to fill all + // available space. + carUiTextView.setMaxWidth(Integer.MAX_VALUE); + } + + carUiTextView.setText( + CarUiTextUtils.fromCarText( + templateContext, action.getTitle(), carTextParams, carUiTextView.getMaxLines())); + } + + // Update the click listener, if one is set. + if (action.getType() == TYPE_BACK) { + setOnClickListener( + v -> { + if (!mIsEnabled) { + showDisabledToast(templateContext); + return; + } + + templateContext.getBackPressedHandler().onBackPressed(); + }); + } else { + OnClickDelegate onClickDelegate = action.getOnClickDelegate(); + if (onClickDelegate != null) { + setOnClickListener( + v -> { + ViewUtils.logCarAppTelemetry(templateContext, UiAction.ACTION_BUTTON_CLICKED); + + if (!mIsEnabled) { + showDisabledToast(templateContext); + return; + } + + CommonUtils.dispatchClick(templateContext, onClickDelegate); + }); + } else { + setOnClickListener(null); + } + } + + // Set the content alignment and margins + View contentView = getChildAt(0); + if (contentView != null) { + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) contentView.getLayoutParams(); + int contentGravity = getContentGravity(mContentAlignment); + layoutParams.gravity = contentGravity; + + // If the content is aligned to the side, use side-alignment-specific horizontal + // margins. + if (contentGravity != Gravity.CENTER) { + layoutParams.leftMargin = mSideAlignmentSpacing; + layoutParams.rightMargin = mSideAlignmentSpacing; + } + + contentView.setLayoutParams(layoutParams); + } + + return this; + } + + /** {@link ActionButtonView} will carry out the action when clicked on without toast. */ + public void enableActionButton() { + mIsEnabled = true; + } + + /** + * {@link ActionButtonView} will show a toast with given message instead of carrying out the + * action when clicked on. + */ + public void disableActionButton(String disabledToastMessage) { + mIsEnabled = false; + mDisabledToastMessage = disabledToastMessage; + } + + private void showDisabledToast(TemplateContext templateContext) { + templateContext.getToastController().showToast(mDisabledToastMessage, Toast.LENGTH_SHORT); + } + + /** Returns {@code true} if action button is enabled. */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public boolean isActionButtonEnabled() { + return mIsEnabled; + } + + /** Gets the gravity value that corresponds to the content alignment value. */ + private static int getContentGravity(int contentAlignment) { + int gravity = Gravity.CENTER_VERTICAL; + switch (contentAlignment) { + case 1: + gravity |= Gravity.LEFT; + break; + case 2: + gravity |= Gravity.RIGHT; + break; + case 0: // fall-through + default: + gravity = Gravity.CENTER; + } + + return gravity; + } + + @Override + protected int[] onCreateDrawableState(int extraSpace) { + int[] additionalStates; + if (mIsPrimary && mIsCustom) { + additionalStates = BUTTON_CUSTOM_PRIMARY; + } else if (mIsPrimary) { + additionalStates = BUTTON_PRIMARY; + } else if (mIsCustom) { + additionalStates = BUTTON_CUSTOM; + } else { + return super.onCreateDrawableState(extraSpace); + } + int[] state = super.onCreateDrawableState(extraSpace + additionalStates.length); + mergeDrawableStates(state, additionalStates); + return state; + } + + private void updateState(boolean isCustom, boolean isPrimary) { + if (isCustom != mIsCustom || isPrimary != mIsPrimary) { + mIsCustom = isCustom; + mIsPrimary = isPrimary; + refreshDrawableState(); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonViewUtils.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonViewUtils.java new file mode 100644 index 0000000..596f9d7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionButtonViewUtils.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.templates.host.view.widgets.common; + +import androidx.annotation.ColorInt; +import androidx.car.app.model.Action; +import com.android.car.libraries.apphost.common.CarColorUtils; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.constraints.CarColorConstraints; + +/** Util class for {@link ActionButtonView}. */ +final class ActionButtonViewUtils { + + /** Returns whether the given action is a primary action. */ + static boolean isPrimaryAction(Action action) { + return (action.getFlags() & Action.FLAG_PRIMARY) != 0; + } + + /** Returns the background color of the given action. */ + static int getBackgroundColor( + TemplateContext templateContext, + Action action, + @ColorInt int surroundingColor, + @ColorInt int defaultBackgroundColor) { + return CarColorUtils.resolveColor( + templateContext, + /* carColor= */ action.getBackgroundColor(), + /* isDark= */ true, + /* defaultColor= */ defaultBackgroundColor, + /* constraints= */ CarColorConstraints.UNCONSTRAINED, + /* backgroundColor= */ surroundingColor); + } + + private ActionButtonViewUtils() {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionListUtils.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionListUtils.java new file mode 100644 index 0000000..4776734 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionListUtils.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import androidx.car.app.model.Action; +import java.util.ArrayList; +import java.util.List; + +/** Util class for {@link Action} lists. */ +public final class ActionListUtils { + /** + * Returns whether the given object is a list of {@link Action}s or not. + * + * <p>An empty list is not considered an action list. + */ + @SuppressWarnings("unchecked") + public static boolean isActionList(Object obj) { + if (!(obj instanceof List)) { + return false; + } + + List<Object> list = (List) obj; + if (list.isEmpty()) { + return false; + } + + // Only check if the first element is an action. When we create a list of actions later, we + // will + // skip non-action elements. + return list.get(0) instanceof Action; + } + + /** + * Returns a list of {@link Action}s if the given object is an action list, and an empty list if + * it is not. + */ + @SuppressWarnings("unchecked") + public static List<Action> getActionList(Object obj) { + List<Action> actionList = new ArrayList<>(); + if (obj instanceof List) { + List<Object> list = (List) obj; + for (Object element : list) { + if (element instanceof Action) { + actionList.add((Action) element); + } + } + } + + return actionList; + } + + private ActionListUtils() {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionStripUtils.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionStripUtils.java new file mode 100644 index 0000000..e4beced --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionStripUtils.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.templates.host.view.widgets.common; + +import androidx.annotation.Nullable; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.template.view.model.ActionStripWrapper; +import com.android.car.libraries.apphost.template.view.model.ActionWrapper; +import com.google.common.collect.ImmutableList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Util class for {@link ActionStrip}. */ +final class ActionStripUtils { + /** + * Validates the {@link ActionStrip} against the {@link ActionsConstraints} instance's required + * types. + * + * @throws ValidationException if the action strip does not meet the required type constraints. + */ + static void validateRequiredTypes( + @Nullable ActionStripWrapper actionStrip, ActionsConstraints constraints) + throws ValidationException { + List<ActionWrapper> actions = + actionStrip == null ? ImmutableList.of() : actionStrip.getActions(); + + // Check for any missing required types. + Set<Integer> requiredActionTypes = constraints.getRequiredActionTypes(); + if (!requiredActionTypes.isEmpty()) { + Set<Integer> requiredTypes = new HashSet<>(requiredActionTypes); + + for (ActionWrapper action : actions) { + requiredTypes.remove(action.get().getType()); + } + + if (!requiredTypes.isEmpty()) { + StringBuilder missingTypeError = new StringBuilder(); + for (int type : requiredTypes) { + missingTypeError.append(Action.typeToString(type)).append(";"); + } + throw new ValidationException("Missing required action types: " + missingTypeError); + } + } + } + + private ActionStripUtils() {} + + /** An exception thrown if the action strip validation fails. */ + static class ValidationException extends Exception { + private ValidationException(String errorMessage) { + super(errorMessage); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionStripView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionStripView.java new file mode 100644 index 0000000..e1f9ed1 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ActionStripView.java @@ -0,0 +1,461 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static android.widget.LinearLayout.VERTICAL; +import static com.android.car.libraries.apphost.template.view.model.ActionStripWrapper.INVALID_FOCUSED_ACTION_INDEX; +import static java.lang.Math.max; +import static java.lang.Math.min; +import static java.util.concurrent.TimeUnit.SECONDS; + +import android.animation.Animator; +import android.animation.AnimatorInflater; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.content.ComponentName; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Handler; +import android.os.Message; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.CarText; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; +import com.android.car.libraries.apphost.common.CarAppError; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.common.ThreadUtils; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.logging.L; +import com.android.car.libraries.apphost.logging.LogTags; +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 com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ActionStripUtils.ValidationException; +import java.util.ArrayList; +import java.util.List; + +/** A view that displays an action strip for the templates. */ +public class ActionStripView extends FrameLayout { + public static final long ACTIONSTRIP_ACTIVE_STATE_DURATION_MILLIS = SECONDS.toMillis(10); + private static final int MSG_ACTIONSTRIP_ACTIVE_STATE = 1; + + /** A delegate that responds to the visibility updates due to active state changes. */ + public interface ActiveStateDelegate { + /** Invoked when the view's visibility changes due to the active state change. */ + void onActiveStateVisibilityChanged(); + } + + private final Handler mHandler = new Handler(new HandlerCallback()); + + private boolean mIsActive = true; + private boolean mAllowTwoLines = false; + private LinearLayout mPrimaryContainer; + private LinearLayout mSecondaryContainer; + private ViewGroup mTouchContainer; + private final int mButtonMargin; + private final int mButtonHeight; + private final int mMinTouchTargetSize; + private TemplateContext mTemplateContext; + @StyleRes private final int mFabStyleResId; + + @Nullable private ActiveStateDelegate mActiveStateDelegate; + + @Nullable ComponentName mAppName; + + public ActionStripView(Context context) { + this(context, null); + } + + public ActionStripView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ActionStripView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings({"ResourceType"}) + public ActionStripView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateActionStripButtonMargin, + R.attr.templateActionButtonHeight, + R.attr.templateActionButtonTouchTargetSize, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mButtonMargin = ta.getDimensionPixelSize(0, 0); + mButtonHeight = ta.getDimensionPixelOffset(1, 0); + mMinTouchTargetSize = ta.getDimensionPixelOffset(2, 0); + ta.recycle(); + + // Get the fab appearance style resource id from the view's attributes. + TypedArray viewStyledAttributes = + context.obtainStyledAttributes( + attrs, R.styleable.ActionStripView, defStyleAttr, defStyleRes); + mFabStyleResId = + viewStyledAttributes.getResourceId(R.styleable.ActionStripView_fabAppearance, -1); + viewStyledAttributes.recycle(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mPrimaryContainer = findViewById(R.id.action_strip_container); + mSecondaryContainer = findViewById(R.id.action_strip_container_secondary); + mTouchContainer = findViewById(R.id.action_strip_touch_container); + } + + /** Returns whether the buttons are allowed to be arranged in two lines. */ + public boolean getAllowTwoLines() { + return mAllowTwoLines; + } + + /** Sets the {@link ActiveStateDelegate} for this action strip view. */ + public void setActiveStateDelegate(ActiveStateDelegate activeStateDelegate) { + this.mActiveStateDelegate = activeStateDelegate; + } + + /** + * Updates the {@link ActionStrip} to the {@link ActionStripView}. + * + * @see {@link #setActionStrip(TemplateContext, ActionStrip, ActionsConstraints, boolean)}. + */ + public void setActionStrip( + TemplateContext templateContext, + @Nullable ActionStrip actionStrip, + ActionsConstraints constraints) { + setActionStrip(templateContext, actionStrip, constraints, false); + } + + /** + * Updates the {@link ActionStrip} to the {@link ActionStripView}. + * + * @see {@link #setActionStrip(TemplateContext, ActionStrip, ActionsConstraints, boolean)}. + */ + public void setActionStrip( + TemplateContext templateContext, + @Nullable ActionStrip actionStrip, + ActionsConstraints constraints, + boolean allowTwoLines) { + ActionStripWrapper actionStripWrapper = + actionStrip == null ? null : new ActionStripWrapper.Builder(actionStrip).build(); + setActionStrip(templateContext, actionStripWrapper, constraints, allowTwoLines); + } + + /** + * Updates the {@link ActionStrip} to the {@link ActionStripView}. + * + * <p>The {@link ActionStrip} will be validated against the given {@link ActionsConstraints} + * instance. If the number of {@link Action}s in the action strip exceeds the max allowed actions + * as specified in the constraints, the {@link Action}s beyond the allowed number will be dropped + * from the view. + * + * <p>If the {@link ActionStrip} is {@code null} or if there are no {@link Action}s added to the + * view, the action strip will be hidden. + * + * <p>If {@code allowTwoLines} is {@code true}, the buttons are positioned in two lines. The last + * two actions will be in the primary container, and the rest in the secondary container. + */ + public void setActionStrip( + TemplateContext templateContext, + @Nullable ActionStripWrapper actionStrip, + ActionsConstraints constraints, + boolean allowTwoLines) { + mAllowTwoLines = allowTwoLines; + mAppName = templateContext.getCarAppPackageInfo().getComponentName(); + mTemplateContext = templateContext; + // Ensure the model satisfies the input constraints. + try { + ActionStripUtils.validateRequiredTypes(actionStrip, constraints); + } catch (ValidationException exception) { + templateContext + .getErrorHandler() + .showError( + CarAppError.builder(templateContext.getCarAppPackageInfo().getComponentName()) + .setCause(exception) + .build()); + } + + if (actionStrip == null) { + setVisibility(GONE); + return; + } + + // Set the host-determined index of the action button to focus. Otherwise, if a button was + // focused, get its index before removing the button views. + int focusedActionIndex = + actionStrip.getFocusedActionIndex() == INVALID_FOCUSED_ACTION_INDEX + ? getCurrentFocusedActionIndex() + : actionStrip.getFocusedActionIndex(); + mPrimaryContainer.removeAllViews(); + mSecondaryContainer.removeAllViews(); + + int maxAllowedActions = constraints.getMaxActions(); + int maxAllowedCustomTitles = constraints.getMaxCustomTitles(); + List<ActionWrapper> actions = actionStrip.getActions(); + List<ActionWrapper> allowedActions = new ArrayList<>(); + + for (ActionWrapper action : actions) { + CarText title = action.get().getTitle(); + if (title != null && !title.isEmpty()) { + if (--maxAllowedCustomTitles < 0) { + L.w( + LogTags.TEMPLATE, + "Dropping actions in action strip over max custom title limit of %d", + constraints.getMaxCustomTitles()); + break; + } + } + + if (--maxAllowedActions < 0) { + L.w( + LogTags.TEMPLATE, + "Dropping actions in action strip over max limit of %d", + constraints.getMaxActions()); + break; + } + + allowedActions.add(action); + } + + // Go through the actions in reverse, and add them to the appropriate containers. If two + // lines are allowed, the last two actions will be in the primary container, and the rest in + // the secondary container. + int lastPrimaryContainerActionIndex = allowTwoLines ? max(allowedActions.size() - 2, 0) : 0; + for (int i = allowedActions.size() - 1; i >= 0; i--) { + ActionWrapper action = allowedActions.get(i); + + FabView fabView = new FabView(getContext(), null, 0, mFabStyleResId); + LinearLayout container = + i >= lastPrimaryContainerActionIndex ? mPrimaryContainer : mSecondaryContainer; + container.addView(fabView, 0); + + // Set the action on a fab view. + fabView.setAction(templateContext, action); + } + + updateFabViewLayoutParams(mPrimaryContainer); + updateFabViewLayoutParams(mSecondaryContainer); + + List<View> actionButtons = getActionButtons(); + int actionCount = actionButtons.size(); + if (actionCount < 1) { + setVisibility(GONE); + } else { + // If a button was focused before, restore the focus. + if (focusedActionIndex >= 0) { + int indexToFocus = min(focusedActionIndex, actionCount - 1); + actionButtons.get(indexToFocus).requestFocus(); + } + + mPrimaryContainer.setVisibility(mPrimaryContainer.getChildCount() > 0 ? VISIBLE : GONE); + mSecondaryContainer.setVisibility(mSecondaryContainer.getChildCount() > 0 ? VISIBLE : GONE); + + // Synchronize the visibility and the FABs clickable states with the active/idle state, + // and do not show the strip's buttons unless it is current active. + setVisibility(mIsActive ? VISIBLE : GONE); + setFabViewClickableState(mIsActive); + } + + updateTouchTarget(); + + ViewUtils.logCarAppTelemetry(templateContext, UiAction.ACTION_STRIP_SIZE, actionCount); + } + + /** + * Requests the action strip is active after the specified delay. + * + * <p>When {@code true}, the action strip fades in if it is not currently visible. If {@code + * false}, the action strip fades out. + * + * <p>If there is a currently pending request to activate/de-activate the action strip that has + * not been processed yet, the previous request will be cancelled and the new request will be + * queued. + */ + public void setActiveStateWithDelay(boolean isActive, long millis) { + mHandler.removeMessages(MSG_ACTIONSTRIP_ACTIVE_STATE); + Message message = mHandler.obtainMessage(MSG_ACTIONSTRIP_ACTIVE_STATE); + message.obj = isActive; + mHandler.sendMessageDelayed(message, millis); + } + + /** + * Sets whether the action strip is active. + * + * <p>This will immediately activate/de-activate the action strip and cancel any pending requests + * that might have been sent via {@link #setActiveStateWithDelay}. + */ + public void setActiveState(boolean isActive) { + mHandler.removeMessages(MSG_ACTIONSTRIP_ACTIVE_STATE); + setActionStateInternal(isActive); + } + + private void setActionStateInternal(boolean isActive) { + if (mIsActive == isActive) { + return; + } + + if (mTemplateContext != null) { + ViewUtils.logCarAppTelemetry( + mTemplateContext, isActive ? UiAction.ACTION_STRIP_SHOW : UiAction.ACTION_STRIP_HIDE); + } + + mIsActive = isActive; + + List<Animator> animations = new ArrayList<>(); + boolean isVisible = isActive && !getActionButtons().isEmpty(); + int animResId = + isVisible ? R.anim.fab_view_animation_fade_in : R.anim.fab_view_animation_fade_out; + for (View actionButton : getActionButtons()) { + Animator animation = AnimatorInflater.loadAnimator(getContext(), animResId); + animation.setTarget(actionButton); + animations.add(animation); + } + + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.playTogether(animations); + animatorSet.setInterpolator(new FastOutSlowInInterpolator()); + animatorSet.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + // Make the Fab clickable/non-clickable as soon as the animation starts. + // Updating this only after the animation has started prevents the user + // clicking in the action strip area to activate the strip, and the FAB + // responding to the same click event. + // TODO(b/165887188): add test for this. + setFabViewClickableState(isActive); + + if (isVisible) { + setVisibility(VISIBLE); + + ActiveStateDelegate delegate = mActiveStateDelegate; + if (delegate != null) { + delegate.onActiveStateVisibilityChanged(); + } + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!isVisible) { + setVisibility(GONE); + + ActiveStateDelegate delegate = mActiveStateDelegate; + if (delegate != null) { + delegate.onActiveStateVisibilityChanged(); + } + } + } + }); + + ThreadUtils.runOnMain(() -> animatorSet.start()); + } + + /** + * Returns the index of the focused button. + * + * <p>If none are focused, returns {@link ActionStripWrapper#INVALID_FOCUSED_ACTION_INDEX}. + */ + private int getCurrentFocusedActionIndex() { + int focusedActionIndex = INVALID_FOCUSED_ACTION_INDEX; + List<View> actionButtons = getActionButtons(); + + for (int i = 0; i < actionButtons.size(); i++) { + View fabView = actionButtons.get(i); + if (fabView.isFocused()) { + focusedActionIndex = i; + break; + } + } + + return focusedActionIndex; + } + + /** Gets all action button views in the action strip. */ + private List<View> getActionButtons() { + ArrayList<View> actionButtons = new ArrayList<>(); + for (int i = 0; i < mSecondaryContainer.getChildCount(); i++) { + actionButtons.add(mSecondaryContainer.getChildAt(i)); + } + for (int i = 0; i < mPrimaryContainer.getChildCount(); i++) { + actionButtons.add(mPrimaryContainer.getChildAt(i)); + } + return actionButtons; + } + + private void updateFabViewLayoutParams(LinearLayout container) { + for (int i = 0; i < container.getChildCount(); i++) { + FabView fabView = (FabView) container.getChildAt(i); + + LinearLayout.LayoutParams layoutParams = + new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, mButtonHeight); + + // Set the margins on buttons after the first one. + if (i > 0) { + if (container.getOrientation() == VERTICAL) { + layoutParams.topMargin = mButtonMargin; + } else { + layoutParams.leftMargin = mButtonMargin; + } + } + + fabView.setLayoutParams(layoutParams); + } + } + + private void setFabViewClickableState(boolean clickable) { + for (View actionButton : getActionButtons()) { + FabView view = (FabView) actionButton; + view.setClickable(clickable); + } + } + + private void updateTouchTarget() { + mTouchContainer.setTouchDelegate(null); + for (View actionButton : getActionButtons()) { + FabView view = (FabView) actionButton; + ViewUtils.setMinTapTarget(mTouchContainer, view, mMinTouchTargetSize); + } + } + + /** A {@link Handler.Callback} for delay activate/de-activate the action strip. */ + private class HandlerCallback implements Handler.Callback { + @Override + public boolean handleMessage(Message msg) { + if (msg.what == MSG_ACTIONSTRIP_ACTIVE_STATE) { + boolean isActive = (boolean) msg.obj; + setActionStateInternal(isActive); + } else { + L.w(LogTags.TEMPLATE, "Unknown message: %s", msg); + } + return false; + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/AndroidManifest.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/AndroidManifest.xml new file mode 100644 index 0000000..595afa7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/AndroidManifest.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<manifest package="com.android.car.libraries.templates.host.view" + xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-sdk android:minSdkVersion="21"/> +</manifest> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/BleedingCardView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/BleedingCardView.java new file mode 100644 index 0000000..79f7750 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/BleedingCardView.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Outline; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.InsetDrawable; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.widget.FrameLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import com.android.car.libraries.apphost.common.CarColorUtils; +import com.android.car.libraries.templates.host.R; +import java.util.Arrays; + +/** + * A card view that "bleeds" through the bottom of its parent. + * + * <p>"Bleeding" means its rounded corners become square at the bottom when the card's bottom is at, + * or past its parent's bottom, thus creating an effect as if the card is "bleeding through" (or + * "peeking out of") the bottom of the parent. + */ +public class BleedingCardView extends FrameLayout { + // Percentage of the length of the card radius that the background radius is reduced by to avoid + // it showing up from underneath the foreground border and creating a subtle but ugly aliasing + // effect + private static final float BACKGROUND_RADIUS_PERCENTAGE = 0.25f; + + private final int mRadius; + private final int mBorderWidth; + @ColorInt private final int mBorderColor; + @ColorInt private int mBackgroundColor; + private final float mWidthFraction; + private final int mMinWidth; + private final int mMaxWidth; + private final int mOemWidth; + private final int mOemMaxWidth; + + public BleedingCardView(Context context) { + this(context, null); + } + + public BleedingCardView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public BleedingCardView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings({"ResourceType", "nullness:method.invocation", "nullness:argument"}) + public BleedingCardView( + Context context, @Nullable AttributeSet attrs, int defStyleAttrs, int defStyleRes) { + super(context, attrs, defStyleAttrs, defStyleRes); + + TypedArray ta = + context.obtainStyledAttributes(attrs, R.styleable.BleedingCardView, defStyleAttrs, 0); + mBorderWidth = ta.getDimensionPixelSize(R.styleable.BleedingCardView_cardBorderWidth, 0); + mBorderColor = ta.getColor(R.styleable.BleedingCardView_cardBorderColor, 0); + @ColorInt + int backgroundColor = ta.getColor(R.styleable.BleedingCardView_cardBackgroundColor, 0); + @ColorInt int textColor = ta.getColor(R.styleable.BleedingCardView_cardTextColor, 0); + @ColorInt + int fallbackDarkBackgroundColor = + ta.getColor(R.styleable.BleedingCardView_cardFallbackDarkBackgroundColor, 0); + @ColorInt + int fallbackLightBackgroundColor = + ta.getColor(R.styleable.BleedingCardView_cardFallbackLightBackgroundColor, 0); + mRadius = ta.getDimensionPixelSize(R.styleable.BleedingCardView_cardRadius, 0); + mWidthFraction = ta.getFloat(R.styleable.BleedingCardView_cardWidthFraction, 0.f); + mMinWidth = ta.getDimensionPixelSize(R.styleable.BleedingCardView_cardMinWidth, 0); + mMaxWidth = ta.getDimensionPixelSize(R.styleable.BleedingCardView_cardMaxWidth, 0); + mOemWidth = ta.getDimensionPixelSize(R.styleable.BleedingCardView_cardOemWidth, 0); + mOemMaxWidth = ta.getDimensionPixelSize(R.styleable.BleedingCardView_cardOemMaxWidth, 0); + ta.recycle(); + + setClipToOutline(true); + + mBackgroundColor = + calculateBackgroundColor( + backgroundColor, textColor, fallbackDarkBackgroundColor, fallbackLightBackgroundColor); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + updateCardBackground(); + } + + public int getCardRadius() { + return mRadius; + } + + @ColorInt + public int getCardBackgroundColor() { + return mBackgroundColor; + } + + /** Sets the background color and triggers an update if it has changed. */ + public void setCardBackgroundColor(@ColorInt int backgroundColor) { + if (mBackgroundColor == backgroundColor) { + return; + } + mBackgroundColor = backgroundColor; + updateCardBackground(); + } + + /** Sets the card width either based on the set {@link #mOemWidth}, or {@link #mWidthFraction}. */ + private void setCardWidthIfNeeded() { + // TODO(b/162419749): Set the percent width in the xml file, without using ConstraintLayout. + if (mOemWidth > 0) { + // If the OEM defined the card width, use it after checking for min and max values. + int cardWidth = mOemWidth; + cardWidth = min(cardWidth, mOemMaxWidth); + cardWidth = max(cardWidth, mMinWidth); + getLayoutParams().width = cardWidth; + } else if (mWidthFraction > 0) { + // If the width fraction is set, use it after checking for min and max values. + int screenWidth = getResources().getDisplayMetrics().widthPixels; + int cardWidth = (int) (screenWidth * mWidthFraction); + cardWidth = min(cardWidth, mMaxWidth); + cardWidth = max(cardWidth, mMinWidth); + getLayoutParams().width = cardWidth; + } + } + + @Override + public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + updateCardBackground(); + } + + /** Returns a background color with proper contrast ratio for the given text color. */ + @ColorInt + private int calculateBackgroundColor( + @ColorInt int backgroundColor, + @ColorInt int textColor, + @ColorInt int fallbackDarkBackgroundColor, + @ColorInt int fallbackLightBackgroundColor) { + if (CarColorUtils.hasMinimumColorContrast(textColor, backgroundColor)) { + return backgroundColor; + } else if (CarColorUtils.hasMinimumColorContrast(textColor, fallbackDarkBackgroundColor)) { + return fallbackDarkBackgroundColor; + } else { + return fallbackLightBackgroundColor; + } + } + + private Drawable createBackground(float[] radii) { + // Create a drawable for the background. + GradientDrawable backDrawable = new GradientDrawable(); + + // Reduce the radius a bit to avoid the background popping from outside of the border. + float reduction = mRadius * BACKGROUND_RADIUS_PERCENTAGE; + float[] backRadii = Arrays.copyOf(radii, 8); + for (int i = 0; i < radii.length; ++i) { + radii[i] -= reduction; + } + backDrawable.setCornerRadii(backRadii); + backDrawable.setColor(mBackgroundColor); + + return backDrawable; + } + + private Drawable createForeground(float[] radii) { + // Create the border drawable. + GradientDrawable borderDrawable = new GradientDrawable(); + + // Blend the border with the background color. This method returns a fully opaque color. We + // do + // this instead of drawing the border over the background with an alpha so that any contents + // of the card get are drawn underneath the border (e.g. the lighter rectangle we display + // over + // the lanes image) don't get blended with the border, and the border is rather of a single + // color. + borderDrawable.setStroke( + mBorderWidth, CarColorUtils.blendColorsSrc(mBorderColor, mBackgroundColor)); + borderDrawable.setCornerRadii(radii); + return borderDrawable; + } + + private void updateCardBackground() { + setCardWidthIfNeeded(); + + // Determine whether the card is bleeding, i.e. if it goes past the bottom of the parent. + boolean isBleeding = isBleeding(); + + // Remove the bottom rounded corners if the card is bleeding. + float bottomRadius = isBleeding ? 0 : mRadius; + float[] radii = + new float[] { + mRadius, mRadius, mRadius, mRadius, bottomRadius, bottomRadius, bottomRadius, bottomRadius + }; + + // Set the background. + setBackground(createBackground(radii)); + + // Set the foreground border. + Drawable foreground = createForeground(radii); + if (isBleeding) { + // If bleeding, inset the bottom with a negative value to hide the bottom border. + foreground = new InsetDrawable(foreground, 0, 0, 0, -mBorderWidth); + } + setForeground(foreground); + + // Set the card view's outline with rounded corners to clip its child views (e.g. junction + // image). + ViewOutlineProvider outlineProvider = + new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + int bottom = view.getHeight(); + if (isBleeding()) { + // If the card view is bleeding, add the radius value so that only the + // top corners are rounded. + bottom += mRadius; + } + outline.setRoundRect(0, 0, view.getWidth(), bottom, mRadius); + } + }; + setOutlineProvider(outlineProvider); + + invalidate(); + } + + private boolean isBleeding() { + ViewGroup parent = (ViewGroup) getParent(); + boolean isBleeding = false; + if (parent != null) { + int parentHeight = parent.getHeight(); + int bottom = getTop() + getHeight(); + if (bottom >= parentHeight) { + isBleeding = true; + } + } + return isBleeding; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarEditText.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarEditText.java new file mode 100644 index 0000000..ea8764e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarEditText.java @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.text.InputType; +import android.util.AttributeSet; +import android.view.ActionMode; +import android.view.ActionMode.Callback; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputConnectionWrapper; +import android.widget.EditText; +import android.widget.TextView; +import androidx.annotation.Nullable; +import com.android.car.libraries.apphost.input.CarEditable; +import com.android.car.libraries.apphost.input.CarEditableListener; +import com.android.car.libraries.apphost.input.InputManager; +import com.android.car.libraries.templates.host.R; + +/** + * A EditText for use in-car. This EditText: + * + * <ul> + * <li>Disables selection + * <li>Disables Cut/Copy/Paste + * <li>Force-disables suggestions + * </ul> + */ +public class CarEditText extends EditText implements CarEditable { + private static final int[] ERROR_STATE = + new int[] {R.attr.state_error}; + private static final boolean SELECTION_CLAMPING_ENABLED = false; + + private int mLastSelEnd = 0; + private int mLastSelStart = 0; + private boolean mCursorClamped; + private boolean mInErrorState; + + private CarEditableListener mCarEditableListener; + private KeyListener mListener; + private InputManager mInputManager; + + /** + * Listener for events when the user interacts with the keyboard similar to {@link + * android.text.method.KeyListener}. + */ + public interface KeyListener { + /** Callback when a key is pressed. */ + void onKeyDown(char key); + + /** Callback when a key is released. */ + void onKeyUp(char key); + + /** Callback when text has been changed by another input connection or copy/paste. */ + void onCommitText(String input); + + /** Callback when the user closes the keyboard. */ + void onCloseKeyboard(); + + /** Callback when the text field has been cleared. */ + void onDelete(); + } + + @SuppressLint("ClickableViewAccessibility") + @SuppressWarnings("nullness") // suppress under initialization warning for this + public CarEditText(Context context, AttributeSet attrs) { + super(context, attrs); + setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + setTextIsSelectable(false); + setLongClickable(false); + setFocusableInTouchMode(true); + setSelection(getText().length()); + mCursorClamped = true; + setOnEditorActionListener( + new OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (mListener != null && actionId == EditorInfo.IME_ACTION_DONE) { + mListener.onCloseKeyboard(); + } + // Return false because we don't want to hijack the default behavior. + return false; + } + }); + setCustomSelectionActionModeCallback( + new Callback() { + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) {} + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return false; + } + }); + setOnTouchListener( + (v, event) -> { + if (MotionEvent.ACTION_UP == event.getAction()) { + mInputManager.startInput(CarEditText.this); + } + return false; + }); + } + + public void setKeyListener(KeyListener listener) { + mListener = listener; + } + + public void setInputManager(InputManager inputManager) { + mInputManager = inputManager; + } + + /** Sets whether this edit box is in error state or not */ + public void setErrorState(boolean inErrorState) { + mInErrorState = inErrorState; + refreshDrawableState(); + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + super.onSelectionChanged(selStart, selEnd); + if (mCursorClamped && SELECTION_CLAMPING_ENABLED) { + setSelection(mLastSelStart, mLastSelEnd); + return; + } + if (mCarEditableListener != null) { + mCarEditableListener.onUpdateSelection(mLastSelStart, mLastSelEnd, selStart, selEnd); + } + mLastSelStart = selStart; + mLastSelEnd = selEnd; + } + + @Override + @Nullable + public ActionMode startActionMode(Callback callback) { + return null; + } + + @Override + protected int[] onCreateDrawableState(int extraSpace) { + final int[] state; + if (mInErrorState) { + state = super.onCreateDrawableState(extraSpace + 1); + mergeDrawableStates(state, ERROR_STATE); + } else { + state = super.onCreateDrawableState(extraSpace); + } + return state; + } + + @Override + public void setCarEditableListener(CarEditableListener listener) { + mCarEditableListener = listener; + } + + @Override + public void setInputEnabled(boolean enabled) { + mCursorClamped = !enabled; + } + + @Override + public boolean performClick() { + boolean result = super.performClick(); + mInputManager.startInput(CarEditText.this); + return result; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + InputConnection inputConnection = super.onCreateInputConnection(outAttrs); + return new InputConnectionWrapper(inputConnection, false) { + @Override + public boolean sendKeyEvent(KeyEvent event) { + // TODO(b/208707793): Remove the handleKeyEventNoWindowFocus if found system side fix for R + if (Build.VERSION.SDK_INT == VERSION_CODES.R) { + return handleKeyEventNoWindowFocus(event); + } + if (mListener != null) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + mListener.onKeyDown((char) event.getKeyCode()); + } else if (event.getAction() == KeyEvent.ACTION_UP) { + mListener.onKeyUp((char) event.getKeyCode()); + } + return true; + } else { + return super.sendKeyEvent(event); + } + } + + private boolean handleKeyEventNoWindowFocus(KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_UP) { + if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) { + return super.deleteSurroundingText(1, 0); + } else { + return super.commitText(Character.toString(event.getNumber()), 1); + } + } + return false; + } + + @Override + public boolean commitText(CharSequence charSequence, int i) { + if (mListener != null) { + mListener.onCommitText(charSequence.toString()); + return true; + } + return super.commitText(charSequence, i); + } + + @Override + public boolean deleteSurroundingText(int i, int i1) { + if (mListener != null) { + mListener.onDelete(); + return true; + } + return super.deleteSurroundingText(i, i1); + } + }; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarEditTextWrapper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarEditTextWrapper.java new file mode 100644 index 0000000..1674db7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarEditTextWrapper.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.annotation.SuppressLint; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.AccessibilityDelegate; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.EditText; +import androidx.annotation.Nullable; +import com.android.car.libraries.apphost.input.CarEditable; +import com.android.car.libraries.apphost.input.CarEditableListener; +import com.android.car.libraries.apphost.input.InputManager; + +/** A wrapper for {@link EditText} to make it conform to {@link CarEditable}. */ +public class CarEditTextWrapper implements CarEditable { + private final EditText mEditText; + private int mLastSelectionEnd = 0; + private int mLastSelectionStart = 0; + @Nullable private CarEditableListener mCarEditableListener; + + @SuppressLint("ClickableViewAccessibility") + @SuppressWarnings("nullness:argument") // Accessing "this" inside click listener. + public CarEditTextWrapper(EditText editText, InputManager inputManager) { + mEditText = editText; + + // Setup an accessibility delegate to get the text selection changes. This is required in + // order + // to conform to the CarEditable which requires a text selection update listener. + AccessibilityDelegate accessibilityDelegate = + new AccessibilityDelegate() { + @Override + public void sendAccessibilityEvent(View host, int eventType) { + super.sendAccessibilityEvent(host, eventType); + if (eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) { + if (mCarEditableListener != null) { + mCarEditableListener.onUpdateSelection( + mLastSelectionStart, + mLastSelectionEnd, + mEditText.getSelectionStart(), + mEditText.getSelectionEnd()); + } + mLastSelectionStart = mEditText.getSelectionStart(); + mLastSelectionEnd = mEditText.getSelectionEnd(); + } + } + }; + editText.setAccessibilityDelegate(accessibilityDelegate); + editText.setOnClickListener((view) -> inputManager.startInput(CarEditTextWrapper.this)); + editText.setOnFocusChangeListener( + (view, hasFocus) -> { + if (!hasFocus) { + inputManager.stopInput(); + } + }); + // Android will dispatch in the following order: + // onTouch + // onFocus + // onClick + // However if internally the view consumes any it will stop dispatching. If an EditText + // does not have focus it will consume the focus and not send the onClick. + editText.setOnTouchListener( + (v, event) -> { + if (MotionEvent.ACTION_UP == event.getAction()) { + inputManager.startInput(CarEditTextWrapper.this); + } + return false; + }); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + return mEditText.onCreateInputConnection(outAttrs); + } + + @Override + public void setCarEditableListener(@Nullable CarEditableListener listener) { + mCarEditableListener = listener; + } + + @Override + public void setInputEnabled(boolean enabled) { + mEditText.setEnabled(enabled); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarImageView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarImageView.java new file mode 100644 index 0000000..0e3d108 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarImageView.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.ImageView; +import androidx.annotation.Nullable; +import com.android.car.libraries.templates.host.R; + +/** An {@link ImageView} that enforces size limits on OEM-customized width and height. */ +@SuppressLint("AppCompatCustomView") +public final class CarImageView extends ImageView { + private final int mMinWidth; + private final int mMaxWidth; + private final int mMinHeight; + private final int mMaxHeight; + + public CarImageView(Context context) { + this(context, null); + } + + public CarImageView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CarImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public CarImageView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + TypedArray ta = + context.obtainStyledAttributes(attrs, R.styleable.CarImageView, defStyleAttr, 0); + mMinWidth = ta.getDimensionPixelSize(R.styleable.CarImageView_imageMinWidth, 0); + mMaxWidth = ta.getDimensionPixelSize(R.styleable.CarImageView_imageMaxWidth, Integer.MAX_VALUE); + mMinHeight = ta.getDimensionPixelSize(R.styleable.CarImageView_imageMinHeight, 0); + mMaxHeight = + ta.getDimensionPixelSize(R.styleable.CarImageView_imageMaxHeight, Integer.MAX_VALUE); + ta.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // Set the OEM-customizable image size, with the min and max limits. + ViewUtils.enforceViewSizeLimit(this, mMinWidth, mMaxWidth, mMinHeight, mMaxHeight); + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarProgressBar.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarProgressBar.java new file mode 100644 index 0000000..8a9fd75 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarProgressBar.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.ProgressBar; +import androidx.annotation.Nullable; +import com.android.car.libraries.templates.host.R; + +/** An {@link ProgressBar} that enforces size limits on OEM-customized width and height. */ +public class CarProgressBar extends ProgressBar { + private final int mMinSize; + private final int mMaxSize; + + public CarProgressBar(Context context) { + this(context, null); + } + + public CarProgressBar(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CarProgressBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public CarProgressBar( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + TypedArray ta = + context.obtainStyledAttributes(attrs, R.styleable.CarProgressBar, defStyleAttr, 0); + mMinSize = ta.getDimensionPixelSize(R.styleable.CarProgressBar_imageMinSize, 0); + mMaxSize = ta.getDimensionPixelSize(R.styleable.CarProgressBar_imageMaxSize, Integer.MAX_VALUE); + ta.recycle(); + } + + @Override + protected synchronized void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // Set the OEM-customizable image size, with the min and max limits. + ViewUtils.enforceViewSizeLimit(this, mMinSize, mMaxSize, mMinSize, mMaxSize); + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarUiTextUtils.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarUiTextUtils.java new file mode 100644 index 0000000..7a92013 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CarUiTextUtils.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.templates.host.view.widgets.common; + +import static java.util.Objects.requireNonNull; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.car.app.model.CarText; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.ui.CarUiText; +import java.util.ArrayList; +import java.util.List; + +/** Util class for {@link CarUiText}. */ +public class CarUiTextUtils { + private CarUiTextUtils() {} + + /** Creates a {@link CarUiText} from a {@link CarText}. */ + public static CarUiText fromCarText( + TemplateContext context, @Nullable CarText carText, int maxLines) { + return fromCarText(context, carText, CarTextParams.DEFAULT, maxLines); + } + + /** Creates a {@link CarUiText} from a {@link CarText}. */ + public static CarUiText fromCarText( + TemplateContext context, @Nullable CarText carText, CarTextParams params, int maxLines) { + if (CarText.isNullOrEmpty(carText)) { + return new CarUiText("", maxLines); + } + requireNonNull(carText); + + List<CharSequence> textVariants = new ArrayList<>(); + textVariants.add(CarTextUtils.toCharSequenceOrEmpty(context, carText, params)); + for (int i = 0; i < carText.getVariants().size(); i++) { + textVariants.add(CarTextUtils.toCharSequenceOrEmpty(context, carText, params, i)); + } + return new CarUiText.Builder(textVariants) + .setMaxLines(maxLines) + .setMaxChars(context.getConstraintsProvider().getStringCharacterLimit()) + .build(); + } + + /** Creates a {@link CarUiText} from a {@link CharSequence}. */ + public static CarUiText fromCharSequence( + TemplateContext context, @NonNull CharSequence charSequence, int maxLines) { + + return new CarUiText.Builder(charSequence) + .setMaxLines(maxLines) + .setMaxChars(context.getConstraintsProvider().getStringCharacterLimit()) + .build(); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CardHeaderView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CardHeaderView.java new file mode 100644 index 0000000..d3c1662 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/CardHeaderView.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.OnClickDelegate; +import androidx.car.app.model.OnContentRefreshDelegate; +import androidx.core.graphics.drawable.IconCompat; +import com.android.car.libraries.apphost.common.CommonUtils; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.TemplateValidator; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; + +/** A view that displays the header for the templates. */ +public class CardHeaderView extends LinearLayout { + private CarUiTextView mHeaderTitle; + private ImageView mHeaderButtonIcon; + private FrameLayout mHeaderButtonContainer; + private FrameLayout mRefreshButtonContainer; + private ImageView mRefreshButtonIcon; + @ColorInt private final int mHeaderIconTint; + + public CardHeaderView(Context context) { + this(context, null); + } + + public CardHeaderView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CardHeaderView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings("nullness:argument") // Fix UnderInitialization warnings + public CardHeaderView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + LayoutInflater.from(context).inflate(R.layout.header_view, this); + + @StyleableRes final int[] themeAttrs = {R.attr.templateHeaderButtonIconTint}; + TypedArray themeAttrsArray = context.obtainStyledAttributes(themeAttrs); + mHeaderIconTint = themeAttrsArray.getColor(0, 0); + themeAttrsArray.recycle(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mHeaderTitle = findViewById(R.id.header_title); + mHeaderButtonContainer = findViewById(R.id.header_button_container); + mHeaderButtonIcon = findViewById(R.id.header_icon); + mRefreshButtonContainer = findViewById(R.id.refresh_button_container); + mRefreshButtonIcon = findViewById(R.id.refresh_icon); + ViewUtils.setMinTapTarget( + this, + mHeaderButtonContainer, + getResources().getDimensionPixelSize(R.dimen.template_min_tap_target_size)); + } + + /** + * Update the {@link HeaderView} to show the given {@code title} and header {@code action}. + * + * <p>If the inputs are {@code null} then the view is hidden. + */ + public void setContent( + TemplateContext templateContext, @Nullable CarText title, @Nullable Action action) { + setContent(templateContext, title, action, null); + } + + /** + * Update the {@link HeaderView} to show the given {@code title}, header {@code action}, and, if + * {@code contentRefreshDelegate} is not {@code null}, a refresh button that allow users to + * interact with to trigger refreshes. + * + * <p>If the inputs are {@code null} then the view is hidden. + */ + public void setContent( + TemplateContext templateContext, + @Nullable CarText title, + @Nullable Action action, + @Nullable OnContentRefreshDelegate contentRefreshDelegate) { + boolean isVisible = title != null; + if (isVisible) { + mHeaderTitle.setText( + CarUiTextUtils.fromCarText(templateContext, title, mHeaderTitle.getMaxLines())); + + mHeaderTitle.setVisibility(VISIBLE); + } else { + mHeaderTitle.setVisibility(GONE); + } + + isVisible |= updateHeaderButton(templateContext, action); + isVisible |= updateRefreshButton(templateContext, contentRefreshDelegate); + setVisibility(isVisible ? VISIBLE : GONE); + } + + /** + * Updates the optional button in the header. + * + * @return true if the button ended up visible, false otherwise. + */ + private boolean updateHeaderButton(TemplateContext templateContext, @Nullable Action action) { + if (action == null) { + mHeaderButtonContainer.setVisibility(GONE); + return false; + } + + mHeaderButtonContainer.setVisibility(VISIBLE); + + ImageUtils.setImageSrc( + templateContext, + ImageUtils.getIconFromAction(action), + mHeaderButtonIcon, + ImageViewParams.builder().setDefaultTint(mHeaderIconTint).setForceTinting(true).build()); + + if (action.getType() == Action.TYPE_APP_ICON) { + // Special treatment for app icon as it is un-clickable and un-focusable. + mHeaderButtonContainer.setFocusable(false); + mHeaderButtonContainer.setClickable(false); + } else if (action.getType() == Action.TYPE_BACK) { + // Special treatment for back as it doesn't have a custom click listener + mHeaderButtonContainer.setOnClickListener( + view -> templateContext.getBackPressedHandler().onBackPressed()); + mHeaderButtonContainer.setFocusable(true); + mHeaderButtonContainer.setClickable(true); + } else { + OnClickDelegate onClickDelegate = action.getOnClickDelegate(); + if (onClickDelegate != null) { + mHeaderButtonContainer.setOnClickListener( + view -> CommonUtils.dispatchClick(templateContext, onClickDelegate)); + mHeaderButtonContainer.setFocusable(true); + mHeaderButtonContainer.setClickable(true); + } else { + mHeaderButtonContainer.setFocusable(false); + mHeaderButtonContainer.setClickable(false); + } + } + + return true; + } + + private boolean updateRefreshButton( + TemplateContext templateContext, @Nullable OnContentRefreshDelegate contentRefreshDelegate) { + if (!templateContext.getCarHostConfig().isPoiContentRefreshEnabled() + || contentRefreshDelegate == null) { + mRefreshButtonContainer.setVisibility(GONE); + mRefreshButtonContainer.setFocusable(false); + mRefreshButtonContainer.setClickable(false); + return false; + } + + CarIcon icon = + new CarIcon.Builder( + IconCompat.createWithResource( + getContext(), templateContext.getHostResourceIds().getRefreshIconDrawable())) + .build(); + ImageUtils.setImageSrc( + templateContext, + icon, + mRefreshButtonIcon, + ImageViewParams.builder().setDefaultTint(mHeaderIconTint).setForceTinting(true).build()); + + mRefreshButtonContainer.setVisibility(VISIBLE); + mRefreshButtonContainer.setFocusable(true); + mRefreshButtonContainer.setClickable(true); + mRefreshButtonContainer.setOnClickListener( + view -> { + TemplateValidator templateValidator = + templateContext.getAppHostService(TemplateValidator.class); + if (templateValidator != null) { + templateValidator.setIsNextTemplateContentRefreshIfSameType(true); + } + templateContext.getAppDispatcher().dispatchContentRefreshRequest(contentRefreshDelegate); + }); + + return true; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ClickableSpanTextContainer.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ClickableSpanTextContainer.java new file mode 100644 index 0000000..a5db03b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ClickableSpanTextContainer.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.text.Selection; +import android.text.Spannable; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.BackgroundColorSpan; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewTreeObserver.OnGlobalFocusChangeListener; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import com.android.car.libraries.templates.host.R; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A container for a {@link TextView} that allows moving focus between clickable spans. + * + * <p>Only the vertical focus movement are supported. + */ +public class ClickableSpanTextContainer extends FrameLayout implements OnGlobalFocusChangeListener { + /** An invalid span index. */ + private static final int INVALID_INDEX = -1; + + private final ForegroundColorSpan mHighlightForegroundSpan; + private final BackgroundColorSpan mHighlightBackgroundSpan; + + private final List<ClickableSpan> mClickableSpans = new ArrayList<>(); + private int mSelectedSpanIndex = INVALID_INDEX; + + /** Indicates whether the focus moved in between spans. */ + private boolean mMovedClickableSpanFocus = false; + + private TextView mClickableSpanCarTextView; + + public ClickableSpanTextContainer(@NonNull Context context) { + this(context, null); + } + + public ClickableSpanTextContainer(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ClickableSpanTextContainer( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public ClickableSpanTextContainer( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateClickableSpanHighlightForegroundColor, + R.attr.templateClickableSpanHighlightBackgroundColor, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + int highlightForegroundColor = ta.getColor(0, Color.TRANSPARENT); + int highlightBackgroundColor = ta.getColor(1, Color.TRANSPARENT); + ta.recycle(); + + mHighlightForegroundSpan = new ForegroundColorSpan(highlightForegroundColor); + mHighlightBackgroundSpan = new BackgroundColorSpan(highlightBackgroundColor); + } + + /** Sets the given text for the wrapped text view. */ + public void setText(@Nullable CharSequence text) { + mClickableSpanCarTextView.setText(text); + + mClickableSpans.clear(); + if (text instanceof Spannable) { + Spannable spannable = (Spannable) text; + Collections.addAll( + mClickableSpans, spannable.getSpans(0, text.length(), ClickableSpan.class)); + } + } + + @Override + public void onGlobalFocusChanged(View oldFocus, View newFocus) { + if (newFocus == null) { + // The focus left the window, remove the link highlight. + removeLinkHighlight(); + return; + } + + if (oldFocus == null) { + // The focus came back to the window, show the link highlight again if applicable. + updateSelectedSpan(); + return; + } + + int focusDirection = focusDirection(oldFocus, newFocus); + if (newFocus.equals(mClickableSpanCarTextView)) { + // The focus moved from another view to this view. Determine which clickable is + // selected. + if (mMovedClickableSpanFocus) { + // The user moved focus, but we brought the focus back to this view after changing + // the + // selected clickable span index to simulate the clickable span focus movement. Do + // not reset + // the index. + mMovedClickableSpanFocus = false; + } else { + if (focusDirection == FOCUS_UP) { + // The focus moved up. Select the last span in the list. + mSelectedSpanIndex = + mClickableSpans.isEmpty() ? INVALID_INDEX : mClickableSpans.size() - 1; + } else { + // The focus moved down. Select the first span in the list. + mSelectedSpanIndex = mClickableSpans.isEmpty() ? INVALID_INDEX : 0; + } + } + + updateSelectedSpan(); + } else if (oldFocus.equals(mClickableSpanCarTextView)) { + // The focus moved from this view to another view. + if (mSelectedSpanIndex != INVALID_INDEX) { + if (focusDirection == FOCUS_UP && mSelectedSpanIndex > 0) { + // Focus moved up within the span list, select an earlier span and focus on the + // text view + // again. + mSelectedSpanIndex--; + mMovedClickableSpanFocus = true; + mClickableSpanCarTextView.requestFocus(); + } else if (focusDirection == FOCUS_DOWN + && mSelectedSpanIndex < mClickableSpans.size() - 1) { + // Focus moved down within the span list, select a later span and focus on the + // text view + // again. + mSelectedSpanIndex++; + mMovedClickableSpanFocus = true; + mClickableSpanCarTextView.requestFocus(); + } else { + // Focus moved out of the span list, remove the selected span. + mSelectedSpanIndex = INVALID_INDEX; + updateSelectedSpan(); + } + } + } + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mClickableSpanCarTextView = findViewById(R.id.clickable_span_text_view); + + // Enable clickable spans here, setting these in the resourc1e file does not work + mClickableSpanCarTextView.setMovementMethod(LinkMovementMethod.getInstance()); + mClickableSpanCarTextView.setHighlightColor(Color.TRANSPARENT); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + getViewTreeObserver().addOnGlobalFocusChangeListener(this); + } + + @Override + protected void onDetachedFromWindow() { + getViewTreeObserver().removeOnGlobalFocusChangeListener(this); + + super.onDetachedFromWindow(); + } + + /** Updates the selected clickable span. */ + private void updateSelectedSpan() { + Spannable spannable = (Spannable) mClickableSpanCarTextView.getText(); + if (spannable == null) { + return; + } + + if (mSelectedSpanIndex == INVALID_INDEX) { + Selection.removeSelection(spannable); + spannable.removeSpan(mHighlightForegroundSpan); + spannable.removeSpan(mHighlightBackgroundSpan); + } else { + // highlight the selected span. + ClickableSpan span = mClickableSpans.get(mSelectedSpanIndex); + int spanStart = spannable.getSpanStart(span); + int spanEnd = spannable.getSpanEnd(span); + Selection.setSelection(spannable, spanStart, spanEnd); + + spannable.setSpan( + mHighlightForegroundSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + spannable.setSpan( + mHighlightBackgroundSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + /** + * Removes the link highlight from the selected span. + * + * <p>This method only removes the visual highlight, but not the selected span. + */ + private void removeLinkHighlight() { + Spannable spannable = (Spannable) mClickableSpanCarTextView.getText(); + if (spannable != null) { + spannable.removeSpan(mHighlightForegroundSpan); + spannable.removeSpan(mHighlightBackgroundSpan); + } + } + + /** + * Determines which direction the focus moved from the old to new focus. + * + * <p>This method only determines the vertical focus direction. + */ + private static int focusDirection(View oldFocus, View newFocus) { + int[] oldLocation = getViewLocationInWindow(oldFocus); + int[] newLocation = getViewLocationInWindow(newFocus); + int oldLocationY = oldLocation[1]; + int newLocationY = newLocation[1]; + return oldLocationY > newLocationY ? FOCUS_UP : FOCUS_DOWN; + } + + private static int[] getViewLocationInWindow(@Nullable View view) { + int[] location = new int[2]; + if (view != null) { + view.getLocationInWindow(location); + } + return location; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ContentView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ContentView.java new file mode 100644 index 0000000..0e731e7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ContentView.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.templates.host.view.widgets.common; + +import static com.android.car.libraries.apphost.template.view.model.RowListWrapper.LIST_FLAGS_HIDE_ROW_DIVIDERS; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import androidx.annotation.Nullable; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.template.view.model.RowListWrapper; +import com.android.car.libraries.templates.host.R; + +/** A view that displays content such as a list, a pane, or an error screen. */ +public class ContentView extends LinearLayout { + private ViewGroup mViewGroup; + + public ContentView(Context context) { + this(context, null); + } + + public ContentView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public ContentView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings({"ResourceType", "method.invocation.invalid", "argument.type.incompatible"}) + public ContentView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mViewGroup = findViewById(R.id.container); + } + + /** Sets a {@link GridWrapper} as the content for this view. */ + public void setGridContent(TemplateContext templateContext, GridWrapper gridWrapper) { + View view = mViewGroup.getChildCount() > 0 ? mViewGroup.getChildAt(0) : null; + if (view != null) { + if (!(view instanceof GridView)) { + removeView(view); + view = null; + } + } + + if (view == null) { + view = LayoutInflater.from(getContext()).inflate(R.layout.grid_view, mViewGroup, false); + mViewGroup.addView(view); + } + + ((GridView) view).setGrid(templateContext, gridWrapper); + } + + /** Sets a {@link RowListWrapper} as the content for this view. */ + public void setRowListContent(TemplateContext templateContext, RowListWrapper rowList) { + View view = mViewGroup.getChildCount() > 0 ? mViewGroup.getChildAt(0) : null; + if (view != null) { + if (!(view instanceof RowListView)) { + removeView(view); + view = null; + } + } + + if (view == null) { + boolean hasRowDividers = (rowList.getListFlags() & LIST_FLAGS_HIDE_ROW_DIVIDERS) == 0; + int layout = + rowList.isHalfList() + ? R.layout.half_list_view + : hasRowDividers ? R.layout.full_list_view : R.layout.full_list_no_divider_view; + view = LayoutInflater.from(getContext()).inflate(layout, mViewGroup, false); + mViewGroup.addView(view); + } + + ((RowListView) view).setRowList(templateContext, rowList); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/FabView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/FabView.java new file mode 100644 index 0000000..23b4211 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/FabView.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static androidx.car.app.model.Action.TYPE_BACK; + +import android.content.Context; +import android.content.res.TypedArray; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.widget.ImageView; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.OnClickDelegate; +import com.android.car.libraries.apphost.common.CommonUtils; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.logging.TelemetryEvent.UiAction; +import com.android.car.libraries.apphost.template.view.model.ActionWrapper; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; + +/** Displays an {@link Action} as a FAB. */ +// TODO(b/158142806): Merge with ActionButtonView +public class FabView extends LinearLayout { + private Object mAction; + private final int mMinWidthWithText; + private final int mMinWidthWithoutText; + @ColorInt private final int mContentColor; + @ColorInt private final int mBackgroundColorLight; + @ColorInt private final int mBackgroundColorDark; + + public FabView(Context context) { + this(context, null); + } + + public FabView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public FabView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public FabView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateActionWithTextMinWidth, + R.attr.templateActionWithoutTextMinWidth, + R.attr.templateActionStripFabBackgroundColorLight, + R.attr.templateActionStripFabBackgroundColorDark + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mMinWidthWithText = ta.getDimensionPixelSize(0, 0); + mMinWidthWithoutText = ta.getDimensionPixelSize(1, 0); + mBackgroundColorLight = ta.getColor(2, 0); + mBackgroundColorDark = ta.getColor(3, 0); + ta.recycle(); + + ta = context.obtainStyledAttributes(defStyleRes, new int[] {R.attr.fabDefaultContentColor}); + mContentColor = ta.getColor(0, -1); + ta.recycle(); + } + + /** Returns whether the button contains a text or not. */ + public boolean hasTitle() { + CarUiTextView textView = findViewById(R.id.action_text); + return textView != null && !TextUtils.isEmpty(textView.getText()); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + } + + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + public Object getAction() { + return mAction; + } + + /** Updates the view from based on the input {@code action}. */ + public void setAction(TemplateContext templateContext, ActionWrapper actionWrapper) { + removeAllViews(); + Action action = actionWrapper.get(); + mAction = action; + + CharSequence title = CarTextUtils.toCharSequenceOrEmpty(templateContext, action.getTitle()); + CarIcon icon = ImageUtils.getIconFromAction(action); + + LayoutInflater inflater = LayoutInflater.from(getContext()); + + boolean hasTitle = title.length() > 0; + setMinimumWidth(hasTitle ? mMinWidthWithText : mMinWidthWithoutText); + + if (icon != null && hasTitle) { + inflater.inflate(R.layout.fab_view_icon_text, this); + } else if (hasTitle) { + inflater.inflate(R.layout.fab_view_text, this); + } else { + inflater.inflate(R.layout.action_button_view_icon, this); + } + + if (icon != null) { + @ColorInt + int backgroundColor = + CommonUtils.isDarkMode(templateContext) ? mBackgroundColorDark : mBackgroundColorLight; + ImageViewParams imageViewParams = + ImageViewParams.builder() + .setDefaultTint(mContentColor) + .setForceTinting(true) + .setBackgroundColor(backgroundColor) + .build(); + + ImageView iconView = findViewById(R.id.action_icon); + ImageUtils.setImageSrc(templateContext, icon, iconView, imageViewParams); + } + + // Add the text view. + if (hasTitle) { + CarUiTextView carUiTextView = findViewById(R.id.action_text); + carUiTextView.setText( + CarUiTextUtils.fromCarText( + templateContext, action.getTitle(), carUiTextView.getMaxLines())); + carUiTextView.setTextColor(mContentColor); + } + + // Update the click listener, if one is set. + if (action.getType() == TYPE_BACK) { + setOnClickListener(v -> templateContext.getBackPressedHandler().onBackPressed()); + } else { + OnClickDelegate onClickDelegate = action.getOnClickDelegate(); + ActionWrapper.OnClickListener hostListener = actionWrapper.getOnClickListener(); + if (onClickDelegate != null || hostListener != null) { + setOnClickListener( + v -> { + if (hostListener != null) { + hostListener.onClick(); + } + + ViewUtils.logCarAppTelemetry(templateContext, UiAction.ACTION_STRIP_FAB_CLICKED); + if (onClickDelegate != null) { + CommonUtils.dispatchClick(templateContext, onClickDelegate); + } + }); + } else { + setOnClickListener(null); + } + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridAdapter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridAdapter.java new file mode 100644 index 0000000..365faae --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridAdapter.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.templates.host.view.widgets.common; + +import static java.lang.Math.min; + +import android.content.Context; + +import android.view.LayoutInflater; +import android.view.ViewGroup; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.constraints.ConstraintManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.recyclerview.CarUiRecyclerView; +import com.google.common.collect.ImmutableList; +import java.util.List; + +/** A grid adapter for {@link GridItemWrapper}s. */ +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +public class GridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> + implements CarUiRecyclerView.ItemCap { + + private final Context mContext; + private final int mItemsPerRow; + private List<GridRowWrapper> mRowWrappers; + private List<GridItemWrapper> mItemWrappers; + + private TemplateContext mTemplateContext; + private int mMaxItemCount; + + static GridAdapter create(Context context, int itemsPerRow) { + return new GridAdapter(context, itemsPerRow); + } + + void setGridItems(TemplateContext templateContext, List<GridItemWrapper> gridItemWrappers) { + mTemplateContext = templateContext; + mMaxItemCount = + mTemplateContext + .getConstraintsProvider() + .getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_GRID); + mItemWrappers = gridItemWrappers; + mRowWrappers = GridRowWrapper.create(gridItemWrappers, mItemsPerRow); + + notifyDataSetChanged(); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { + return new RecyclerView.ViewHolder( + LayoutInflater.from(mContext).inflate(R.layout.grid_item_view, viewGroup, false)) {}; + } + + @Override + public void onBindViewHolder(ViewHolder viewHolder, int index) { + GridItemWrapper gridItemWrapper = mItemWrappers.get(index); + GridRowWrapper gridRowWrapper = findGridRowWrapperForItemAt(index); + ((GridItemView) viewHolder.itemView) + .setGridItem( + mTemplateContext, + gridItemWrapper, + gridRowWrapper.hasGridItemsWithTitle(), + gridRowWrapper.hasGridItemsWithText()); + } + + @Override + public int getItemCount() { + if (mMaxItemCount == CarUiRecyclerView.ItemCap.UNLIMITED) { + return mItemWrappers.size(); + } else { + return min(mItemWrappers.size(), mMaxItemCount); + } + } + + @Override + public void setMaxItems(int maxItems) { + TemplateContext templateContext = mTemplateContext; + if (templateContext == null) { + return; + } + + int gridMaxLength = + templateContext + .getConstraintsProvider() + .getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_GRID); + if (maxItems == CarUiRecyclerView.ItemCap.UNLIMITED) { + mMaxItemCount = gridMaxLength; + } else { + mMaxItemCount = min(maxItems, gridMaxLength); + } + } + + @VisibleForTesting + public List<GridRowWrapper> getRowWrappers() { + return mRowWrappers; + } + + @VisibleForTesting + public List<GridItemWrapper> getItemWrappers() { + return mItemWrappers; + } + + /** Returns the {@link GridRowWrapper} associated with the item at given index. */ + private GridRowWrapper findGridRowWrapperForItemAt(int index) { + int currentIndex = index; + for (GridRowWrapper gridRowWrapper : mRowWrappers) { + int rowItemsCount = gridRowWrapper.getGridRowItems().size(); + if (currentIndex < rowItemsCount) { + return gridRowWrapper; + } + currentIndex -= rowItemsCount; + } + + throw new IndexOutOfBoundsException( + String.format("index = %d >= %d = count", index, getItemCount())); + } + + private GridAdapter(Context context, int itemsPerRow) { + mContext = context; + mItemWrappers = ImmutableList.of(); + mRowWrappers = ImmutableList.of(); + mItemsPerRow = itemsPerRow; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridItemView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridItemView.java new file mode 100644 index 0000000..d03f278 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridItemView.java @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.CarColor; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.GridItem; +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.template.view.model.SelectionGroup; +import com.android.car.libraries.apphost.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; + +/** A view that can display a {@link GridItem} model. */ +public class GridItemView extends LinearLayout { + private static final int[] STATE_INACTIVE_FOCUS = {R.attr.templateFocusStateInactive}; + + /** Text parameters for secondary text in a grid item. */ + private static final CarTextParams TEXT_PARAMS_SECONDARY_TEXT = + CarTextParams.builder() + .setColorSpanConstraints(CarColorConstraints.STANDARD_ONLY) + .setMaxImages(0) + .build(); + /** + * Indicates whether or not this grid item has inactive focus. + * + * <p>The grid item has an inactive focus when it is not clickable. + */ + private boolean mHasInactiveFocus; + + private final int mLargeImageSizeMin; + private final int mLargeImageSizeMax; + @ColorInt private final int mDefaultIconTint; + @ColorInt private final int mBackgroundColor; + private final int mHorizontalTextBottomPadding; + private final Drawable mGridItemBackground; + + private LinearLayout mImageContainer; + private LinearLayout mTextContainer; + private CarUiTextView mTitleView; + private CarUiTextView mTextview; + private ImageView mImageView; + private ProgressBar mProgressBar; + private int mTextTopPadding; + private int mTextBottomPadding; + + public GridItemView(Context context) { + this(context, null); + } + + public GridItemView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + @SuppressWarnings("nullness:assignment") + public GridItemView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateLargeImageSizeMin, + R.attr.templateLargeImageSizeMax, + R.attr.templateGridItemDefaultIconTint, + R.attr.templateGridItemTextBottomPadding, + R.attr.templateGridItemBackground, + R.attr.templateGridItemBackgroundColor, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mLargeImageSizeMin = ta.getDimensionPixelSize(0, 0); + mLargeImageSizeMax = ta.getDimensionPixelSize(1, Integer.MAX_VALUE); + mDefaultIconTint = ta.getColor(2, 0); + mHorizontalTextBottomPadding = ta.getDimensionPixelSize(3, 0); + mGridItemBackground = ta.getDrawable(4); + mBackgroundColor = ta.getColor(5, 0); + ta.recycle(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mImageContainer = findViewById(R.id.grid_item_image_container); + mTextContainer = findViewById(R.id.grid_item_text_container); + mTitleView = findViewById(R.id.grid_item_title); + mTextview = findViewById(R.id.grid_item_text); + mImageView = findViewById(R.id.grid_item_image); + mProgressBar = findViewById(R.id.grid_item_progress_bar); + + // Cache TextContainer padding since the padding is updated every time {@link #setGridItem} is + // called. + mTextTopPadding = mTextContainer.getPaddingTop(); + mTextBottomPadding = mTextContainer.getPaddingBottom(); + + ViewUtils.enforceViewSizeLimit(mImageContainer, mLargeImageSizeMin, mLargeImageSizeMax); + } + + @Override + protected int[] onCreateDrawableState(int extraSpace) { + if (mHasInactiveFocus) { + // We are going to add 1 extra state. + final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); + + mergeDrawableStates(drawableState, STATE_INACTIVE_FOCUS); + return drawableState; + } else { + return super.onCreateDrawableState(extraSpace); + } + } + + /** Updates the view with the given {@link GridItemWrapper}. */ + public void setGridItem( + TemplateContext templateContext, + GridItemWrapper gridItemWrapper, + boolean shouldShowTitle, + boolean shouldShowText) { + GridItem gridItem = gridItemWrapper.getGridItem(); + + L.v(LogTags.TEMPLATE, "Setting grid item view with grid item: %s", gridItem); + + // Unset any click/focus listeners tied to the previous content. New ones will be added + // below. + setOnClickListener(null); + setOnFocusChangeListener(null); + + updateTextView( + templateContext, mTitleView, gridItem.getTitle(), CarTextParams.DEFAULT, shouldShowTitle); + + // Allow standard colors for the secondary text only if the color contrast check passed. + boolean colorContrastCheckPassed = + checkColorContrast(templateContext, gridItem, mBackgroundColor); + CarTextParams secondaryTextParams = + colorContrastCheckPassed ? TEXT_PARAMS_SECONDARY_TEXT : CarTextParams.DEFAULT; + updateTextView( + templateContext, mTextview, gridItem.getText(), secondaryTextParams, shouldShowText); + + mTextContainer.setPadding( + 0, + mTextTopPadding, + 0, + + // If there is not secondary text to be shown, add an extra padding at the bottom of + // the + // container. This makes it so that there's more separation between rows when + // there's only + // a title (for example, in the system's wallpaper picker), while we use up some + // more + // of the vertical space for text when there is a secondary line. + mTextBottomPadding + (shouldShowText ? 0 : mHorizontalTextBottomPadding)); + + SelectionGroup selectionGroup = gridItemWrapper.getSelectionGroup(); + OnClickDelegate onClickDelegate = gridItem.getOnClickDelegate(); + + boolean isLoading = gridItem.isLoading(); + + // The grid item is clickable iff... + boolean isClickable = + // ...it is not in the loading state, and + !isLoading + && ( + // ...it has a click listener coming from the client + onClickDelegate != null + // ...is selectable + || selectionGroup != null); + + // Show either the image or the loading spinner. + mProgressBar.setVisibility(isLoading ? VISIBLE : GONE); + mImageView.setVisibility(isLoading ? GONE : VISIBLE); + if (!isLoading) { + int imageType = gridItem.getImageType(); + + // Show the grid item image. + CarIcon image = gridItem.getImage(); + ImageUtils.setImageSrc( + templateContext, + image, + mImageView, + ImageViewParams.builder() + .setDefaultTint(mDefaultIconTint) + .setForceTinting(imageType == GridItem.IMAGE_TYPE_ICON) + .setBackgroundColor(mBackgroundColor) + .setIgnoreAppTint(!colorContrastCheckPassed) + .build()); + + // Set the onClickListener on the grid item iff... + if (onClickDelegate != null) { + // ...it has a click listener from the client. Dispatch click event to the + // onClickListener. + setOnClickListener( + v -> { + CommonUtils.dispatchClick(templateContext, onClickDelegate); + }); + } else if (selectionGroup != null) { + // ...it is part of a selection group. Dispatch a selection change event to the + // selection + // group's onSelectedListener. + setOnClickListener( + v -> { + int currentSelectionIndex = selectionGroup.getSelectedIndex(); + int newIndex = gridItemWrapper.getGridItemIndex(); + + if (currentSelectionIndex != newIndex) { + selectionGroup.setSelectedIndex(newIndex); + } + + // Dispatch the selection callbacks. + // Note the selection event is dispatched regardless of selection index + // actually + // changing. + templateContext + .getAppDispatcher() + .dispatchSelected( + selectionGroup.getOnSelectedDelegate(), + selectionGroup.getRelativeIndex(newIndex)); + }); + } + } + + setClickable(isClickable); + setBackground(mGridItemBackground); + setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); + setInactiveFocus(!isClickable); + } + + /** Checks the color contrast between contents of the given grid item and the background color. */ + private static boolean checkColorContrast( + TemplateContext templateContext, GridItem gridItem, @ColorInt int backgroundColor) { + // Only the secondary text can be colored, so check it + CarText secondaryText = gridItem.getText(); + if (secondaryText != null) { + if (!CarTextUtils.checkColorContrast(templateContext, secondaryText, backgroundColor)) { + return false; + } + } + + CarIcon image = gridItem.getImage(); + if (image == null) { + return true; + } + CarColor tint = image.getTint(); + if (tint == null) { + return true; + } + + return CarColorUtils.checkColorContrast(templateContext, tint, backgroundColor); + } + + private static void updateTextView( + TemplateContext templateContext, + CarUiTextView carUiTextView, + @Nullable CarText text, + CarTextParams textParams, + boolean shouldShowTextView) { + // The visibility of the text view inside a grid view depends on all the grid items in the + // row. It's possible that this particular grid item doesn't have a valid title or text, but + // another grid item in the row may have a title. We need to have consistent height and + // focus states for all the grid items in a gird row. Using information provided by the grid + // row container to decide the visibility of text view's inside a grid item. + carUiTextView.setVisibility(shouldShowTextView ? VISIBLE : GONE); + + // With the "normal" buffer type, the text view sets a spanned text with immutable spans. + // BufferType.SPANNABLE allows mutable spans, but causes issues with ellipsized texts + // (See b/157754626). + carUiTextView.setText( + CarUiTextUtils.fromCarText(templateContext, text, textParams, carUiTextView.getMaxLines())); + } + + /** @see #mHasInactiveFocus */ + private void setInactiveFocus(boolean hasInactiveFocus) { + if (mHasInactiveFocus != hasInactiveFocus) { + mHasInactiveFocus = hasInactiveFocus; + + // Refresh the drawable state so that it includes the inactive focus state. + refreshDrawableState(); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridItemWrapper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridItemWrapper.java new file mode 100644 index 0000000..8c40bad --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridItemWrapper.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import androidx.annotation.Nullable; +import androidx.car.app.model.GridItem; +import com.android.car.libraries.apphost.template.view.model.SelectionGroup; + +/** A host side wrapper for {@link GridItem}. */ +public class GridItemWrapper { + private final GridItem mGridItem; + private final int mGridItemIndex; + + /** + * The selection group this grid item belongs to, or {@code null} if the grid item does not belong + * to one. + * + * <p>Selection groups are used to establish mutually-exclusive scopes of grid item selection. + */ + @Nullable private final SelectionGroup mSelectionGroup; + + /** Returns a {@link Builder} that wraps a grid item with the provided index. */ + public static Builder wrap( + GridItem gridItem, int gridItemIndex, @Nullable SelectionGroup selectionGroup) { + Builder builder = new Builder(gridItem, gridItemIndex); + if (selectionGroup != null) { + builder.setSelectionGroup(selectionGroup); + } + return builder; + } + + private GridItemWrapper(Builder builder) { + mGridItem = builder.mGridItem; + mGridItemIndex = builder.mGridItemIndex; + mSelectionGroup = builder.mSelectionGroup; + } + + @Override + public String toString() { + return "[" + mGridItem + ", group: " + mSelectionGroup + "]"; + } + + /** Returns the actual {@link GridItem} object that this instance is wrapping. */ + public GridItem getGridItem() { + return mGridItem; + } + + /** Returns the absolute index of the grid item in the flattened container list. */ + public int getGridItemIndex() { + return mGridItemIndex; + } + + @Nullable + SelectionGroup getSelectionGroup() { + return mSelectionGroup; + } + + /** The builder class for {@link GridItemWrapper}. */ + public static class Builder { + private final GridItem mGridItem; + private final int mGridItemIndex; + @Nullable private SelectionGroup mSelectionGroup; + + private Builder(GridItem gridItem, int gridItemIndex) { + mGridItem = gridItem; + mGridItemIndex = gridItemIndex; + } + + /** + * Sets the selection group this grid item belongs to, or {@code null} if the grid item does not + * belong to one. + */ + public Builder setSelectionGroup(@Nullable SelectionGroup selectionGroup) { + mSelectionGroup = selectionGroup; + return this; + } + + /** Build the {@link GridItemWrapper}. */ + public GridItemWrapper build() { + return new GridItemWrapper(this); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridRowWrapper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridRowWrapper.java new file mode 100644 index 0000000..48a9851 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridRowWrapper.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static java.lang.Math.min; + +import androidx.car.app.model.CarText; +import androidx.car.app.model.GridItem; +import java.util.ArrayList; +import java.util.List; + +/** A host side wrapper for a list of {@link GridItem}s that represent a row of the grid. */ +public class GridRowWrapper { + private final List<GridItemWrapper> mGridRowItems; + private final int mGridRowIndex; + private final int mMaxColsPerGridRow; + + private GridRowWrapper( + List<GridItemWrapper> gridRowItems, int gridRowIndex, int maxColsPerGridRow) { + mGridRowItems = gridRowItems; + mGridRowIndex = gridRowIndex; + mMaxColsPerGridRow = maxColsPerGridRow; + } + + public List<GridItemWrapper> getGridRowItems() { + return mGridRowItems; + } + + public int getGridRowIndex() { + return mGridRowIndex; + } + + public int getMaxColsPerGridRow() { + return mMaxColsPerGridRow; + } + + /** + * Creates a list of {@link GridRowWrapper}s from the provided list of {@link GridItemWrapper}s + * based on the {@code numberOfColumns}. + */ + public static List<GridRowWrapper> create( + List<GridItemWrapper> gridItemWrappers, int numberOfColumns) { + List<GridRowWrapper> gridRowWrappers = new ArrayList<>(); + + int itemCount = gridItemWrappers.size(); + int gridRowIndex = 0; + int beginIndex = 0; + while (beginIndex < itemCount) { + gridRowWrappers.add( + new GridRowWrapper( + gridItemWrappers.subList(beginIndex, min(itemCount, beginIndex + numberOfColumns)), + gridRowIndex, + numberOfColumns)); + gridRowIndex++; + beginIndex += numberOfColumns; + } + + return gridRowWrappers; + } + + /** + * Returns {@code true} if any of the {@link GridItem}s consisting this grid row has a title set. + */ + public boolean hasGridItemsWithTitle() { + for (GridItemWrapper gridItemWrapper : mGridRowItems) { + CarText carText = gridItemWrapper.getGridItem().getTitle(); + if (carText != null && !carText.isEmpty()) { + return true; + } + } + + return false; + } + + /** + * Returns {@code true} if any of the {@link GridItem}s consisting this grid row has a secondary + * line of text set. + */ + public boolean hasGridItemsWithText() { + for (GridItemWrapper gridItemWrapper : mGridRowItems) { + CarText carText = gridItemWrapper.getGridItem().getText(); + if (carText != null && !carText.isEmpty()) { + return true; + } + } + + return false; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridView.java new file mode 100644 index 0000000..5a2890a --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridView.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.content.Context; +import android.content.res.TypedArray; +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.CarText; +import androidx.car.app.model.GridItem; +import androidx.car.app.model.OnItemVisibilityChangedDelegate; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.logging.TelemetryEvent.UiAction; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.recyclerview.CarUiLayoutStyle; +import com.android.car.ui.recyclerview.CarUiRecyclerView; +import com.android.car.ui.recyclerview.CarUiRecyclerView.CarUiRecyclerViewLayout; +import com.android.car.ui.widget.CarUiTextView; +import java.util.Objects; + +/** A view that can render a grid of {@link GridItem}s wrapped inside a {@link GridWrapper}. */ +public class GridView extends FrameLayout { + private final AdapterDataObserver mAdapterDataObserver = + new AdapterDataObserver() { + // call to update() not allowed on the given receiver. + @SuppressWarnings("nullness:method.invocation") + @Override + public void onChanged() { + super.onChanged(); + update(); + } + }; + + /** The number of items in a grid row. */ + private final int mItemsPerRow; + + private GridAdapter mGridRowAdapter; + + private ViewGroup mProgressContainer; + private CarUiTextView mEmptyListTextView; + private CarUiRecyclerView mRecyclerView; + private RowVisibilityObserver mRowVisibilityObserver; + private boolean mIsLoading; + + public GridView(Context context) { + this(context, null); + } + + public GridView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public GridView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings({"ResourceType", "nullness:method.invocation", "nullness:argument"}) + public GridView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateGridItemsPerRow, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mItemsPerRow = ta.getInteger(0, 0); + ta.recycle(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mProgressContainer = findViewById(R.id.progress_container); + mEmptyListTextView = findViewById(R.id.list_no_items_text); + mRecyclerView = findViewById(R.id.grid_paged_list_view); + mRecyclerView.setLayoutStyle( + new CarUiLayoutStyle() { + @Override + public int getSpanCount() { + return mItemsPerRow; + } + + @Override + public int getLayoutType() { + return CarUiRecyclerViewLayout.GRID; + } + + @Override + public int getOrientation() { + return CarUiLayoutStyle.VERTICAL; + } + + @Override + public boolean getReverseLayout() { + return false; + } + + @Override + public int getSize() { + return CarUiRecyclerView.SIZE_LARGE; + } + }); + mRowVisibilityObserver = RowVisibilityObserver.create(Objects.requireNonNull(mRecyclerView)); + mGridRowAdapter = GridAdapter.create(getContext(), mItemsPerRow); + mRecyclerView.setAdapter(mGridRowAdapter); + mGridRowAdapter.registerAdapterDataObserver(mAdapterDataObserver); + update(); + } + + void setGrid(TemplateContext templateContext, GridWrapper gridWrapper) { + boolean isLoading = gridWrapper.isLoading(); + if (mIsLoading != isLoading) { + // Trigger a visibility update if the loading state has changed. + mIsLoading = isLoading; + update(); + + if (mIsLoading) { + // Do not update the GridPagedListView/GridRowAdapter, as we want to maintain the + // grid items list size during the loading phase until the new content is populated. + return; + } + } + + CarText emptyListCarText = gridWrapper.getEmptyListText(); + CharSequence emptyText; + if (emptyListCarText != null && !emptyListCarText.isEmpty()) { + emptyText = + CarTextUtils.toCharSequenceOrEmpty(templateContext, gridWrapper.getEmptyListText()); + } else { + emptyText = + templateContext.getText( + templateContext.getHostResourceIds().getTemplateListNoItemsText()); + } + mEmptyListTextView.setText( + CarUiTextUtils.fromCharSequence( + templateContext, emptyText, mEmptyListTextView.getMaxLines())); + mRowVisibilityObserver.setOnItemVisibilityChangedListener( + (startIndexInclusive, endIndexExclusive) -> { + OnItemVisibilityChangedDelegate onItemVisibilityChangedDelegate = + gridWrapper.getOnItemVisibilityChangedDelegate(); + if (onItemVisibilityChangedDelegate != null) { + templateContext + .getAppDispatcher() + .dispatchItemVisibilityChanged( + onItemVisibilityChangedDelegate, startIndexInclusive, endIndexExclusive); + } + }); + + mGridRowAdapter.setGridItems(templateContext, gridWrapper.getGridItemWrappers()); + + if (!gridWrapper.isRefresh()) { + mRecyclerView.scrollToPosition(0); + } + + ViewUtils.logCarAppTelemetry( + templateContext, UiAction.GRID_ITEM_LIST_SIZE, gridWrapper.getGridItemWrappers().size()); + } + + private void update() { + boolean isLoading = mIsLoading; + if (isLoading) { + mProgressContainer.setVisibility(VISIBLE); + + // Mark the content views as invisible so that the size of the container remains the + // same + // while the progress bar is showing. + mEmptyListTextView.setVisibility(INVISIBLE); + mRecyclerView.setVisibility(INVISIBLE); + return; + } + + mProgressContainer.setVisibility(GONE); + + // If the grid item list is empty, hide it and display a message instead. + boolean isEmpty = mGridRowAdapter.getItemCount() == 0; + if (isEmpty) { + mEmptyListTextView.setVisibility(VISIBLE); + mRecyclerView.setVisibility(GONE); + + // When the empty list text view is displayed, show the focus ring by not clipping + // children. + setClipChildren(false); + mEmptyListTextView.setFocusable(true); + } else { + mEmptyListTextView.setVisibility(GONE); + mRecyclerView.setVisibility(VISIBLE); + + // When the grid view is displayed, clip its rows that get out of the view boundary. + setClipChildren(true); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridWrapper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridWrapper.java new file mode 100644 index 0000000..688e29c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/GridWrapper.java @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import androidx.annotation.Nullable; +import androidx.car.app.model.CarText; +import androidx.car.app.model.GridItem; +import androidx.car.app.model.GridTemplate; +import androidx.car.app.model.Item; +import androidx.car.app.model.ItemList; +import androidx.car.app.model.OnItemVisibilityChangedDelegate; +import androidx.car.app.model.OnSelectedDelegate; +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.SelectionGroup; +import com.google.common.collect.ImmutableList; +import java.util.List; + +/** A host side wrapper for {@link ItemList} that's part of a {@link GridTemplate}. */ +public class GridWrapper { + private final boolean mIsLoading; + private final boolean mIsRefresh; + @Nullable private final CarText mEmptyListText; + @Nullable private final OnItemVisibilityChangedDelegate mOnItemVisibilityChangedDelegate; + private final List<GridItemWrapper> mGridItemWrappers; + + /** Converts an {@link ItemList} into a {@link GridWrapper.Builder}. */ + public static Builder wrap(@Nullable ItemList itemList) { + if (itemList == null) { + return new Builder(); + } + + List<Item> gridItems = itemList.getItems(); + Builder builder = + new Builder() + .setGridItems(gridItems) + .setEmptyListText(itemList.getNoItemsMessage()) + .setOnItemVisibilityChangedDelegate(itemList.getOnItemVisibilityChangedDelegate()); + + OnSelectedDelegate onSelectedDelegate = itemList.getOnSelectedDelegate(); + if (onSelectedDelegate != null) { + builder.setSelectionGroup( + SelectionGroup.create( + 0, gridItems.size() - 1, itemList.getSelectedIndex(), onSelectedDelegate)); + } + + return builder; + } + + private GridWrapper(Builder builder) { + mIsLoading = builder.mIsLoading; + mIsRefresh = builder.mIsRefresh; + mEmptyListText = builder.mEmptyListText; + mOnItemVisibilityChangedDelegate = builder.mOnItemVisibilityChangedDelegate; + mGridItemWrappers = buildGridItemWrappers(builder.mGridItems, builder.mSelectionGroup); + } + + @Nullable + public OnItemVisibilityChangedDelegate getOnItemVisibilityChangedDelegate() { + return mOnItemVisibilityChangedDelegate; + } + + public boolean isEmpty() { + return mGridItemWrappers.isEmpty(); + } + + @Nullable + CarText getEmptyListText() { + return mEmptyListText; + } + + List<GridItemWrapper> getGridItemWrappers() { + return mGridItemWrappers; + } + + boolean isLoading() { + return mIsLoading; + } + + boolean isRefresh() { + return mIsRefresh; + } + + /** Builds the {@link GridItemWrapper}s for a given list. */ + private static ImmutableList<GridItemWrapper> buildGridItemWrappers( + @Nullable List<Item> gridItems, @Nullable SelectionGroup selectionGroup) { + if (gridItems == null || gridItems.isEmpty()) { + return ImmutableList.of(); + } + + int beginIndex = 0; + ImmutableList.Builder<GridItemWrapper> gridItemWrapperBuilder = new ImmutableList.Builder<>(); + for (Item item : gridItems) { + if (!(item instanceof GridItem)) { + L.w(LogTags.TEMPLATE, "Item in list is not a GridItem, dropping item"); + } + gridItemWrapperBuilder.add( + GridItemWrapper.wrap((GridItem) item, beginIndex, selectionGroup).build()); + beginIndex++; + } + + return gridItemWrapperBuilder.build(); + } + + /** The builder class for {@link GridWrapper}. */ + public static class Builder { + @Nullable private List<Item> mGridItems; + @Nullable private OnItemVisibilityChangedDelegate mOnItemVisibilityChangedDelegate; + private boolean mIsLoading; + private boolean mIsRefresh; + @Nullable private CarText mEmptyListText; + @Nullable private SelectionGroup mSelectionGroup; + + private Builder() { + mGridItems = null; + } + + /** Sets the grid items in the {@link Builder} */ + public Builder setGridItems(List<Item> gridItems) { + mGridItems = gridItems; + return this; + } + + /** + * Sets the {@link OnItemVisibilityChangedDelegate} which can receive callbacks when the + * visibility of a items changes. + * + * <p>If set to {@code null} it will clear the delegate and no callbacks will be received. + */ + 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 grid items added + * to the list. If set to {@code false}, the UI shows the actual grid item contents. + */ + public Builder setIsLoading(boolean isLoading) { + mIsLoading = isLoading; + return this; + } + + /** + * Sets whether the grid is a refresh of the existing grid. + * + * <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 be displayed when there are no items in the list. */ + public Builder setEmptyListText(@Nullable CarText emptyListText) { + mEmptyListText = emptyListText; + return this; + } + + /** + * Sets the selection group these grid items belong to, or {@code null} if the grid items do not + * belong to one. + */ + public Builder setSelectionGroup(@Nullable SelectionGroup selectionGroup) { + mSelectionGroup = selectionGroup; + return this; + } + + /** Builds the {@link GridWrapper}. */ + public GridWrapper build() { + return new GridWrapper(this); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/HeaderView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/HeaderView.java new file mode 100644 index 0000000..56ec415 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/HeaderView.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.templates.host.view.widgets.common; + +import android.view.View; +import androidx.annotation.Nullable; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarText; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.ui.core.CarUi; +import com.android.car.ui.toolbar.ToolbarController; + +/** A view that displays the header for the templates. */ +public class HeaderView extends AbstractHeaderView { + + private HeaderView(TemplateContext templateContext, ToolbarController toolbarController) { + super(templateContext, toolbarController); + } + + /** + * Set or clear the content of the view. + * + * <p>If the {@code title} is {@code null} then the view is hidden. + */ + public void setContent( + TemplateContext templateContext, @Nullable CarText title, @Nullable Action action) { + mToolbarController.setTitle(CarTextUtils.toCharSequenceOrEmpty(templateContext, title)); + setAction(action); + } + + /** Installs a {@link HeaderView} around the given container view. */ + @SuppressWarnings("nullness:argument") // InsetsChangedListener is nullable. + public static HeaderView install(TemplateContext mTemplateContext, View container) { + ToolbarController toolbarController = CarUi.installBaseLayoutAround(container, null, true); + if (toolbarController == null) { + throw new NullPointerException("Toolbar Controller could not be created."); + } + return new HeaderView(mTemplateContext, toolbarController); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/InputSignInView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/InputSignInView.java new file mode 100644 index 0000000..c8932d2 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/InputSignInView.java @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static android.text.InputType.TYPE_CLASS_NUMBER; +import static android.text.InputType.TYPE_CLASS_PHONE; +import static android.text.InputType.TYPE_CLASS_TEXT; +import static android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD; +import static android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; +import static android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; +import static android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; +import static androidx.car.app.model.signin.InputSignInMethod.INPUT_TYPE_PASSWORD; +import static androidx.car.app.model.signin.InputSignInMethod.KEYBOARD_DEFAULT; +import static androidx.car.app.model.signin.InputSignInMethod.KEYBOARD_EMAIL; +import static androidx.car.app.model.signin.InputSignInMethod.KEYBOARD_NUMBER; +import static androidx.car.app.model.signin.InputSignInMethod.KEYBOARD_PHONE; + +import android.content.Context; +import android.content.res.TypedArray; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.LinearLayout; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.CarText; +import androidx.car.app.model.InputCallbackDelegate; +import androidx.car.app.model.signin.InputSignInMethod; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.input.CarEditable; +import com.android.car.libraries.apphost.input.CarEditableListener; +import com.android.car.libraries.apphost.input.InputManager; +import com.android.car.libraries.apphost.logging.L; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; + +/** A view that displays {@link InputSignInMethod} UI. */ +public class InputSignInView extends LinearLayout implements CarEditable { + private final int mMaxWidth; + + private CarEditText mSignInEditText; + private CarUiTextView mSignInEditTextErrorMessage; + @Nullable private TextWatcher mTextWatcher; + + public InputSignInView(Context context) { + this(context, null); + } + + public InputSignInView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public InputSignInView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings("ResourceType") + public InputSignInView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateSignInMethodViewMaxWidth, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mMaxWidth = ta.getDimensionPixelSize(0, 0); + ta.recycle(); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo editorInfo) { + return mSignInEditText.onCreateInputConnection(editorInfo); + } + + @Override + public void setCarEditableListener(CarEditableListener listener) {} + + @Override + public void setInputEnabled(boolean enabled) {} + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int measuredWidth = MeasureSpec.getSize(widthMeasureSpec); + if (mMaxWidth > 0 && mMaxWidth < measuredWidth) { + int measureMode = MeasureSpec.getMode(widthMeasureSpec); + widthMeasureSpec = MeasureSpec.makeMeasureSpec(mMaxWidth, measureMode); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + /** Sets the {@link InputSignInMethod} for the view. */ + public void setSignInMethod( + TemplateContext templateContext, + InputSignInMethod inputSignInMethod, + InputManager inputManager, + CharSequence disabledInputHint, + boolean isRefresh) { + clearEditTextListeners(); + + // TODO(b/183434044): move this logic to CarRestrictedEditText + setInputHint(templateContext, inputSignInMethod, disabledInputHint); + + // TODO(b/183434044): move this logic to CarRestrictedEditText + setInitialText(templateContext, inputSignInMethod, isRefresh); + + setErrorMessage(templateContext, inputSignInMethod); + + // TODO(b/183434044): move this logic to CarRestrictedEditText + setInputType(inputSignInMethod); + + setShowKeyboardByDefault(templateContext, inputSignInMethod, inputManager); + + // Make sure to set these at the end so that setting initial text etc doesn't trigger text + // change callbacks. + setEditTextListeners(templateContext, inputSignInMethod, inputManager); + } + + /** Clears the edit text focus. */ + public void clearEditTextFocus() { + mSignInEditText.clearFocus(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mSignInEditText = findViewById(R.id.input_sign_in_box); + mSignInEditTextErrorMessage = findViewById(R.id.input_sign_in_error_message); + } + + private void clearEditTextListeners() { + mSignInEditText.setOnClickListener(null); + mSignInEditText.setOnEditorActionListener(null); + if (mTextWatcher != null) { + mSignInEditText.removeTextChangedListener(mTextWatcher); + mTextWatcher = null; + } + } + + private void setEditTextListeners( + TemplateContext templateContext, + InputSignInMethod inputSignInMethod, + InputManager inputManager) { + mSignInEditText.setOnEditorActionListener( + (view, actionId, event) -> { + inputManager.stopInput(); + String inputText = mSignInEditText.getText().toString().trim(); + if (TextUtils.isEmpty(inputText)) { + return false; + } else { + submitInput(templateContext, inputText, inputSignInMethod); + return true; + } + }); + mTextWatcher = + new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence text, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence text, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable text) { + updateInputText(templateContext, text.toString(), inputSignInMethod); + } + }; + mSignInEditText.addTextChangedListener(mTextWatcher); + mSignInEditText.setInputManager(inputManager); + } + + private void setInputHint( + TemplateContext templateContext, + InputSignInMethod inputSignInMethod, + CharSequence disabledInputHint) { + CarText inputHint = inputSignInMethod.getHint(); + CharSequence hint = CarTextUtils.toCharSequenceOrEmpty(templateContext, inputHint); + mSignInEditText.setHint(mSignInEditText.isEnabled() ? hint : disabledInputHint); + } + + private void setInitialText( + TemplateContext templateContext, InputSignInMethod inputSignInMethod, boolean isRefresh) { + CarText initialText = inputSignInMethod.getDefaultValue(); + + CharSequence text = mSignInEditText.getText(); + if (!isRefresh || text == null || text.length() == 0) { + mSignInEditText.setText(CarTextUtils.toCharSequenceOrEmpty(templateContext, initialText)); + mSignInEditText.setSelection(mSignInEditText.getText().length()); + } + } + + private void setErrorMessage( + TemplateContext templateContext, InputSignInMethod inputSignInMethod) { + CarText errorMessage = inputSignInMethod.getErrorMessage(); + if (!CarText.isNullOrEmpty(errorMessage)) { + mSignInEditTextErrorMessage.setText( + CarUiTextUtils.fromCarText( + templateContext, errorMessage, mSignInEditTextErrorMessage.getMaxLines())); + mSignInEditTextErrorMessage.setVisibility(VISIBLE); + mSignInEditText.setErrorState(true); + } else { + mSignInEditTextErrorMessage.setVisibility(GONE); + mSignInEditText.setErrorState(false); + } + } + + private void setInputType(InputSignInMethod inputSignInMethod) { + int inputType; + switch (inputSignInMethod.getKeyboardType()) { + case KEYBOARD_PHONE: + inputType = TYPE_CLASS_PHONE; + break; + case KEYBOARD_NUMBER: + inputType = TYPE_CLASS_NUMBER; + if (inputSignInMethod.getInputType() == INPUT_TYPE_PASSWORD) { + inputType |= TYPE_NUMBER_VARIATION_PASSWORD; + } + break; + case KEYBOARD_EMAIL: + inputType = + TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_EMAIL_ADDRESS | TYPE_TEXT_FLAG_NO_SUGGESTIONS; + break; + case KEYBOARD_DEFAULT: + default: + inputType = TYPE_CLASS_TEXT | TYPE_TEXT_FLAG_NO_SUGGESTIONS; + if (inputSignInMethod.getInputType() == INPUT_TYPE_PASSWORD) { + inputType |= TYPE_TEXT_VARIATION_PASSWORD; + } + } + mSignInEditText.setInputType(inputType); + } + + private void setShowKeyboardByDefault( + TemplateContext templateContext, + InputSignInMethod inputSignInMethod, + InputManager inputManager) { + boolean isRestricted = templateContext.getConstraintsProvider().isConfigRestricted(); + if (inputSignInMethod.isShowKeyboardByDefault() && !isRestricted) { + inputManager.startInput(this); + } + } + + private void submitInput( + TemplateContext templateContext, String inputText, InputSignInMethod inputSignInMethod) { + InputCallbackDelegate delegate = inputSignInMethod.getInputCallbackDelegate(); + if (delegate != null) { + templateContext.getAppDispatcher().dispatchInputSubmitted(delegate, inputText); + } else { + L.w(LogTags.TEMPLATE, "Input callback is expected on the template but not set"); + } + } + + private void updateInputText( + TemplateContext templateContext, String inputText, InputSignInMethod inputSignInMethod) { + InputCallbackDelegate delegate = inputSignInMethod.getInputCallbackDelegate(); + if (delegate != null) { + templateContext.getAppDispatcher().dispatchInputTextChanged(delegate, inputText); + } else { + L.w(LogTags.TEMPLATE, "Input callback is expected on the template but not set"); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/MarkerFactory.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/MarkerFactory.java new file mode 100644 index 0000000..fa1db8c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/MarkerFactory.java @@ -0,0 +1,592 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static com.android.car.libraries.apphost.view.common.ImageUtils.SCALE_CENTER_XY_INSIDE; +import static com.android.car.libraries.apphost.view.common.ImageUtils.SCALE_FIT_CENTER; +import static java.lang.Math.max; +import static java.util.Objects.requireNonNull; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Paint.Align; +import android.graphics.Paint.Cap; +import android.graphics.Path; +import android.graphics.PorterDuff; +import android.graphics.PorterDuff.Mode; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.car.app.model.CarColor; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.PlaceMarker; +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; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import org.checkerframework.checker.initialization.qual.UnderInitialization; +import org.checkerframework.checker.initialization.qual.UnknownInitialization; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** A factory of bitmaps to be used as map markers in different templates. */ +public class MarkerFactory { + // Cache the default anchor so that we don't have to draw if users did not customize. + private final Bitmap mDefaultAnchorBitmap; + + // Cache the default marker so that we don't have to draw if users did not customize. + private final Bitmap mDefaultMarkerBitmap; + + // Mask for clipping an image within bounds. A marker image's draw area is slightly bigger than + // icons with rounded corners. + @MonotonicNonNull private Bitmap mMarkerImageMask; + private final Paint mMarkerImageMaskPaint; + + // Default path used for drawing the standard-size map marker. + private final Path mDefaultMarkerPath; + private final MarkerAppearance mAppearance; + + /** Create a MarkerFactory */ + public static MarkerFactory create(Context context, MarkerAppearance appearance) { + return new MarkerFactory(context, appearance); + } + + /** Returns a map marker bitmap with the given {@link PlaceMarker} configuration. */ + // TODO(b/144920236): cache and reuse bitmaps when applicable. + public Bitmap createPoiMarkerBitmap( + TemplateContext templateContext, @Nullable PlaceMarker marker) { + if (marker == null) { + return mDefaultMarkerBitmap; + } + + // Use the dark variant for background color. + @ColorInt int markerColor = resolveMarkerColor(templateContext, marker); + boolean useDefaultMarker = markerColor == mAppearance.mMarkerDefaultBackgroundColor; + + CarText label = marker.getLabel(); + CarIcon icon = marker.getIcon(); + if (label == null && icon == null && useDefaultMarker) { + return mDefaultMarkerBitmap; + } + + boolean needWideMarker = false; + Bitmap markerBitmap; + String labelString = label == null ? null : label.toString(); + if (icon == null && labelString != null) { + // If we need to draw a label, check if we need to draw a wider marker to fit the text. + Rect bounds = new Rect(); + mAppearance.mDefaultTextPaint.getTextBounds(labelString, 0, labelString.length(), bounds); + + if (bounds.width() > mAppearance.mMarkerSize - mAppearance.mTextHorizontalPadding * 2) { + needWideMarker = true; + markerBitmap = + Bitmap.createBitmap( + bounds.width() + mAppearance.mTextHorizontalPadding * 2, + mAppearance.mMarkerSize + mAppearance.mMarkerPointerHeight, + Config.ARGB_8888); + markerBitmap.setDensity(mAppearance.mDensityDpi); + } else { + markerBitmap = Bitmap.createBitmap(mDefaultMarkerBitmap); + } + } else { + markerBitmap = Bitmap.createBitmap(mDefaultMarkerBitmap); + } + + Canvas canvas = new Canvas(markerBitmap); + if (needWideMarker || !useDefaultMarker) { + drawMarker( + canvas, + mAppearance, + markerColor, + useDefaultMarker + ? mAppearance.mMarkerDefaultBorderColor + : mAppearance.mMarkerCustomBorderColor); + } + + Bitmap contentBitmap = getContentForMapMarker(templateContext, marker, useDefaultMarker); + if (contentBitmap != null) { + canvas.drawBitmap( + contentBitmap, + // Width may have been adjusted so we use the bitmap's width as source of truth. + (markerBitmap.getWidth() - contentBitmap.getWidth()) / 2f, + (mAppearance.mMarkerSize - contentBitmap.getHeight()) / 2f, + mAppearance.mDefaultTextPaint); + } + + return markerBitmap; + } + + /** + * Returns the bitmap representing the content (icon of text) that should appear within a marker + * in the map view, or {code null} if the marker's content is not specified. + */ + @Nullable + private Bitmap getContentForMapMarker( + TemplateContext templateContext, PlaceMarker marker, boolean hasDefaultBackground) { + CarText label = marker.getLabel(); + String labelString = label != null ? label.toString() : null; + CarIcon icon = marker.getIcon(); + Bitmap contentBitmap = null; + + // The icon value takes precedence over the label if both are set. + if (icon != null) { + contentBitmap = + getIconBitmap(templateContext, marker, icon, CommonUtils.isDarkMode(templateContext)); + } else if (labelString != null && !labelString.isEmpty()) { + contentBitmap = + ImageUtils.getBitmapFromString( + labelString, + hasDefaultBackground + ? mAppearance.mDefaultTextPaint + : mAppearance.mCustomBackgroundTextPaint); + } + + if (contentBitmap != null) { + contentBitmap.setDensity(mAppearance.mDensityDpi); + } + + return contentBitmap; + } + + /** + * Returns the bitmap representing the content (icon of text) of the given marker, or {code null} + * if the marker's content is not specified. + */ + @Nullable + public Bitmap getContentForListMarker(TemplateContext templateContext, PlaceMarker marker) { + CarText label = marker.getLabel(); + String labelString = label != null ? label.toString() : null; + CarIcon icon = marker.getIcon(); + Bitmap contentBitmap = null; + + // The icon value takes precedence over the label if both are set. + if (icon != null) { + // We always use the light-variant tint for list marker because the card background is + // dark. + contentBitmap = getIconBitmap(templateContext, marker, icon, /* isDark= */ false); + } else if (labelString != null && !labelString.isEmpty()) { + // We use the light-variant color for the text in the list. + int resolvedColor = + CarColorUtils.resolveColor( + templateContext, + marker.getColor(), + + // The background of the card is dark so use the light variant for the + // text color. + /* isDark= */ false, + mAppearance.mDefaultTextPaint.getColor(), + CarColorConstraints.UNCONSTRAINED); + Paint paint; + if (resolvedColor == mAppearance.mCustomBackgroundTextPaint.getColor()) { + paint = mAppearance.mCustomBackgroundTextPaint; + } else { + paint = new Paint(mAppearance.mCustomBackgroundTextPaint); + paint.setColor(resolvedColor); + } + + Resources resources = templateContext.getResources(); + Drawable bitmap = + new BitmapDrawable(resources, ImageUtils.getBitmapFromString(labelString, paint)); + contentBitmap = + ImageUtils.getBitmapFromDrawable( + bitmap, + mAppearance.mListIconSize, + mAppearance.mListIconSize, + resources.getDisplayMetrics().densityDpi, + SCALE_CENTER_XY_INSIDE); + } + + if (contentBitmap != null) { + contentBitmap.setDensity(mAppearance.mDensityDpi); + } + + return contentBitmap; + } + + @Nullable + private Bitmap getIconBitmap( + TemplateContext templateContext, PlaceMarker marker, CarIcon icon, boolean isDark) { + Bitmap contentBitmap; + boolean isImage = isMarkerImage(marker); + int bitmapSize = isImage ? mAppearance.mImageSize : mAppearance.mIconSize; + ImageViewParams imageParams = + ImageViewParams.builder() + .setDefaultTint(mAppearance.mDefaultIconTint) + .setForceTinting(!isImage) + .setIsDark(isDark) + .build(); + contentBitmap = + ImageUtils.getBitmapFromIcon( + templateContext, icon, bitmapSize, bitmapSize, imageParams, SCALE_FIT_CENTER); + + if (contentBitmap == null) { + L.e(LogTags.TEMPLATE, "Failed to get bitmap for marker: %s", marker); + } else if (isImage) { + // Apply masking to get the rounded corner effect. + Bitmap maskedImage = Bitmap.createBitmap(bitmapSize, bitmapSize, Config.ARGB_8888); + maskedImage.setDensity(mAppearance.mDensityDpi); + + Canvas maskedCanvas = new Canvas(maskedImage); + maskedCanvas.drawBitmap(getOrCreateMarkerImageMask(mAppearance), 0, 0, null); + maskedCanvas.drawBitmap(contentBitmap, 0, 0, mMarkerImageMaskPaint); + contentBitmap = maskedImage; + } + return contentBitmap; + } + + /** Returns an {@link Bitmap} which has been adjusted for a given background color. */ + public Bitmap getAnchorBitmap(TemplateContext templateContext, @Nullable CarColor background) { + if (background == null) { + return mDefaultAnchorBitmap; + } + + @ColorInt + int resolvedBackground = + CarColorUtils.resolveColor( + templateContext, + background, + // Use the dark-variant in day mode, and vice versa. + /* isDark= */ !CommonUtils.isDarkMode(templateContext), + mAppearance.mAnchorDefaultBackgroundColor, + CarColorConstraints.UNCONSTRAINED); + if (resolvedBackground == mAppearance.mAnchorDefaultBackgroundColor) { + return mDefaultAnchorBitmap; + } + + return createAnchorBitmap(templateContext, resolvedBackground, mAppearance); + } + + /** Draw a rounded-corner marker with a pointer at the bottom center. */ + private void drawMarker( + @UnknownInitialization MarkerFactory this, + Canvas canvas, + MarkerAppearance appearance, + @ColorInt int backgroundColor, + @ColorInt int borderColor) { + int defaultMarkerWidth = appearance.mMarkerSize; + int defaultMarkerHeight = appearance.mMarkerSize + appearance.mMarkerPointerHeight; + + Path markerPath = + canvas.getWidth() == defaultMarkerWidth + ? mDefaultMarkerPath + : createMarkerPath(canvas.getWidth(), defaultMarkerHeight, appearance); + if (markerPath == null) { + return; + } + + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setStyle(Paint.Style.FILL); + paint.setColor(backgroundColor); + canvas.drawPath(markerPath, paint); + + paint.setStyle(Paint.Style.STROKE); + paint.setColor(borderColor); + paint.setStrokeWidth(appearance.mMarkerStroke); + paint.setStrokeCap(Cap.ROUND); + canvas.drawPath(markerPath, paint); + } + + private Bitmap createDefaultMarkerBitmap( + @UnderInitialization MarkerFactory this, MarkerAppearance appearance) { + Bitmap markerBitmap = + Bitmap.createBitmap( + appearance.mMarkerSize, + appearance.mMarkerSize + appearance.mMarkerPointerHeight, + Config.ARGB_8888); + markerBitmap.setDensity(appearance.mDensityDpi); + + Canvas canvas = new Canvas(markerBitmap); + drawMarker( + canvas, + appearance, + appearance.mMarkerDefaultBackgroundColor, + appearance.mMarkerDefaultBorderColor); + + return markerBitmap; + } + + private Bitmap createAnchorBitmap( + @UnknownInitialization MarkerFactory this, + Context context, + @ColorInt int backgroundColor, + MarkerAppearance appearance) { + Resources resources = context.getResources(); + + Drawable markerBackground = resources.getDrawable(R.drawable.anchor_marker); + markerBackground.setBounds( + 0, 0, markerBackground.getIntrinsicWidth(), markerBackground.getIntrinsicHeight()); + markerBackground.setColorFilter(backgroundColor, PorterDuff.Mode.SRC_IN); + + Drawable markerBorder = resources.getDrawable(R.drawable.anchor_marker_border); + markerBorder.setBounds( + 0, 0, markerBorder.getIntrinsicWidth(), markerBorder.getIntrinsicHeight()); + markerBorder.setColorFilter(appearance.mAnchorBorderColor, PorterDuff.Mode.SRC_IN); + + Drawable markerDot = resources.getDrawable(R.drawable.anchor_marker_circle); + markerDot.setBounds(0, 0, markerDot.getIntrinsicWidth(), markerDot.getIntrinsicHeight()); + markerDot.setColorFilter(appearance.mAnchorDotColor, PorterDuff.Mode.SRC_IN); + + Bitmap bitmap = + Bitmap.createBitmap( + markerBackground.getIntrinsicWidth(), + markerBackground.getIntrinsicHeight(), + Config.ARGB_8888); + bitmap.setDensity(appearance.mDensityDpi); + + Canvas canvas = new Canvas(bitmap); + markerBackground.draw(canvas); + markerBorder.draw(canvas); + markerDot.draw(canvas); + + return bitmap; + } + + private Bitmap getOrCreateMarkerImageMask(MarkerAppearance appearance) { + if (mMarkerImageMask != null) { + return mMarkerImageMask; + } + + mMarkerImageMask = + Bitmap.createBitmap(appearance.mImageSize, appearance.mImageSize, Config.ALPHA_8); + mMarkerImageMask.setDensity(appearance.mDensityDpi); + + Canvas canvas = new Canvas(mMarkerImageMask); + canvas.drawRoundRect( + 0, + 0, + appearance.mImageSize, + appearance.mImageSize, + appearance.mImageCornerRadius, + appearance.mImageCornerRadius, + new Paint()); + + return mMarkerImageMask; + } + + /** + * Returns the marker color that should be used based on the {@link PlaceMarker}'s configuration. + * + * <p>If the marker is of type {@link PlaceMarker#TYPE_IMAGE}, then the default color will be + * used. Otherwise, we resolve the color provided via the marker to what is defined in our theme. + */ + @ColorInt + private int resolveMarkerColor(TemplateContext templateContext, PlaceMarker marker) { + // We do not support rendering a background color for images. + if (marker.getIconType() == PlaceMarker.TYPE_IMAGE) { + return mAppearance.mMarkerDefaultBackgroundColor; + } + + @ColorInt + int resolvedColor = + CarColorUtils.resolveColor( + templateContext, + marker.getColor(), + // Use the dark-variant in day mode for better contrast with the + // light-colored map, and + // vice versa. + /* isDark= */ !CommonUtils.isDarkMode(templateContext), + mAppearance.mMarkerDefaultBackgroundColor, + CarColorConstraints.UNCONSTRAINED); + return resolvedColor; + } + + private MarkerFactory(Context context, MarkerAppearance appearance) { + mAppearance = appearance; + + mDefaultMarkerPath = + createMarkerPath( + appearance.mMarkerSize, + appearance.mMarkerSize + appearance.mMarkerPointerHeight, + appearance); + mDefaultMarkerBitmap = createDefaultMarkerBitmap(appearance); + mDefaultAnchorBitmap = + createAnchorBitmap(context, appearance.mAnchorDefaultBackgroundColor, appearance); + mDefaultAnchorBitmap.setDensity(appearance.mDensityDpi); + + mMarkerImageMaskPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mMarkerImageMaskPaint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN)); + } + + /** + * Create a {@link Path} representing a map marker that fits within the input width and height. + */ + private static Path createMarkerPath(int width, int height, MarkerAppearance appearance) { + // Actual draw region needs to account for the stroke size. + // At the end, we offset the drawing by (markerStroke / 2) in both x and y to center it. + int drawWidth = width - appearance.mMarkerStroke; + int drawHeight = height - appearance.mMarkerStroke; + int cornerRadius = appearance.mMarkerCornerRadius; + int cornerDiameter = cornerRadius * 2; + float halfStroke = appearance.mMarkerStroke / 2f; + + // Start from the pointer tip and draw clockwise. + float startX = drawWidth / 2f; + float startY = drawHeight; + + // Bottom of the rectangular region of the marker. + float rectBottom = startY - appearance.mMarkerPointerHeight; + float pointerHalfWidth = appearance.mMarkerPointerWidth / 2f; + + Path path = new Path(); + RectF cornerRect = new RectF(); + + path.moveTo(startX, startY); + path.lineTo(startX - pointerHalfWidth, rectBottom); + path.lineTo(appearance.mMarkerCornerRadius, rectBottom); + cornerRect.set(0, rectBottom - cornerDiameter, cornerDiameter, rectBottom); + path.arcTo(cornerRect, 90, 90, false); + + path.lineTo(0, cornerRadius); + cornerRect = new RectF(0, 0, cornerDiameter, cornerDiameter); + path.arcTo(cornerRect, 180, 90, false); + + path.lineTo(drawWidth - cornerRadius, 0); + cornerRect.set(drawWidth - cornerDiameter, 0, drawWidth, cornerDiameter); + path.arcTo(cornerRect, 270, 90, false); + + path.lineTo(drawWidth, rectBottom - cornerRadius); + cornerRect.set(drawWidth - cornerDiameter, rectBottom - cornerDiameter, drawWidth, rectBottom); + path.arcTo(cornerRect, 0, 90, false); + + path.lineTo(startX + pointerHalfWidth, rectBottom); + path.close(); + + // Offset the path to accommodate the stroke so the drawing is centered within the region. + path.offset(halfStroke, halfStroke); + + return path; + } + + private static boolean isMarkerImage(PlaceMarker marker) { + return marker.getIconType() == PlaceMarker.TYPE_IMAGE; + } + + /** Contains the attributes that define the marker's appearance. */ + public static class MarkerAppearance { + @ColorInt private final int mMarkerDefaultBackgroundColor; + @ColorInt private final int mMarkerDefaultBorderColor; + @ColorInt private final int mMarkerCustomBorderColor; + private final int mMarkerSize; + private final int mMarkerPointerWidth; + private final int mMarkerPointerHeight; + private final int mMarkerStroke; + private final int mMarkerCornerRadius; + @ColorInt private final int mAnchorDefaultBackgroundColor; + @ColorInt private final int mAnchorBorderColor; + @ColorInt private final int mAnchorDotColor; + + @ColorInt private final int mDefaultIconTint; + + private final int mIconSize; + private final int mTextHorizontalPadding; + private final int mImageSize; + private final int mImageCornerRadius; + private final Paint mDefaultTextPaint; + private final Paint mCustomBackgroundTextPaint; + + private final int mDensityDpi; + private final int mListIconSize; + + /** + * Creates an instance of a {@link MarkerAppearance} by reading it from the styled attributes in + * the given context's theme. + */ + public MarkerAppearance( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + // Get the marker appearance style resource id from the view's attributes. + TypedArray viewStyledAttributes = + context.obtainStyledAttributes(attrs, R.styleable.PlaceMarker, defStyleAttr, defStyleRes); + int resId = viewStyledAttributes.getResourceId(R.styleable.PlaceMarker_markerAppearance, -1); + viewStyledAttributes.recycle(); + + // No need to pass default values here, the style should contain all these values, which + // can be ensured by using a default style resource by the caller. + TypedArray ta = context.obtainStyledAttributes(resId, R.styleable.MarkerAppearance); + + @ColorInt + int defaultContentColor = + ta.getColor(R.styleable.MarkerAppearance_markerDefaultContentColor, -1); + + // Set up the paint for the text of the marker's label. + mDefaultTextPaint = + new Paint(Paint.LINEAR_TEXT_FLAG | Paint.SUBPIXEL_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG); + mDefaultTextPaint.setTextAlign(Align.CENTER); + mDefaultTextPaint.setTypeface( + Typeface.create( + requireNonNull(ta.getString(R.styleable.MarkerAppearance_android_fontFamily)), + ta.getInt(R.styleable.MarkerAppearance_android_textStyle, -1))); + mDefaultTextPaint.setColor(defaultContentColor); + mDefaultTextPaint.setTextSize( + ta.getDimensionPixelSize(R.styleable.MarkerAppearance_android_textSize, -1)); + + mCustomBackgroundTextPaint = new Paint(mDefaultTextPaint); + mCustomBackgroundTextPaint.setColor( + ta.getColor(R.styleable.MarkerAppearance_markerCustomBackgroundContentColor, -1)); + + // All other marker/anchor related dimensions and colors + mMarkerDefaultBackgroundColor = + ta.getInt(R.styleable.MarkerAppearance_markerDefaultBackgroundColor, -1); + mMarkerDefaultBorderColor = + ta.getInt(R.styleable.MarkerAppearance_markerDefaultBorderColor, -1); + mMarkerCustomBorderColor = + ta.getInt(R.styleable.MarkerAppearance_markerCustomBorderColor, -1); + mMarkerPointerWidth = + ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerPointerWidth, -1); + mMarkerPointerHeight = + ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerPointerHeight, -1); + mMarkerStroke = ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerStroke, -1); + mMarkerCornerRadius = + ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerCornerRadius, -1); + mAnchorDefaultBackgroundColor = + ta.getInt(R.styleable.MarkerAppearance_anchorDefaultBackgroundColor, -1); + mAnchorBorderColor = ta.getInt(R.styleable.MarkerAppearance_anchorBorderColor, -1); + mAnchorDotColor = ta.getInt(R.styleable.MarkerAppearance_anchorDotColor, -1); + mTextHorizontalPadding = + ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerTextHorizontalPadding, -1); + mIconSize = ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerIconSize, -1); + mImageSize = ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerImageSize, -1); + mImageCornerRadius = + ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerImageCornerRadius, -1); + + mDefaultIconTint = defaultContentColor; + + int markerPadding = ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerPadding, -1); + mMarkerSize = max(mIconSize, mImageSize) + mMarkerStroke + markerPadding; + + mListIconSize = ta.getDimensionPixelSize(R.styleable.MarkerAppearance_markerListIconSize, -1); + + ta.recycle(); + + mDensityDpi = context.getResources().getDisplayMetrics().densityDpi; + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/PanOverlayView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/PanOverlayView.java new file mode 100644 index 0000000..607cd18 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/PanOverlayView.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** Overlay view to display on top of the map surface in the pan mode. */ +public class PanOverlayView extends FrameLayout { + + public PanOverlayView(@NonNull Context context) { + this(context, null); + } + + public PanOverlayView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public PanOverlayView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public PanOverlayView( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ParkedOnlyFrameLayout.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ParkedOnlyFrameLayout.java new file mode 100644 index 0000000..34d84e9 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ParkedOnlyFrameLayout.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.templates.host.view.widgets.common; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.android.car.libraries.apphost.common.EventManager; +import com.android.car.libraries.apphost.common.EventManager.EventType; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; + +/** + * Custom view that hides content and shows an appropriate message when car is driving. This view + * will hide all children views while the driving message is being shown. Usage of this layout + * should use a single container layout as a child, and visibility of that child should not be + * modified outside of this layout. This layout does not maintain any visibility states of children + * views before or after drive state changes. This means that if the visibility of children views + * are updated directly the visibility may not be consistent after the driving message disappears. + */ +public class ParkedOnlyFrameLayout extends FrameLayout { + + private View mDrivingMessageView; + + private boolean mIsLockedOut; + private TemplateContext mTemplateContext; + private EventManager mEventManager; + + public ParkedOnlyFrameLayout(@NonNull Context context) { + super(context); + } + + public ParkedOnlyFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public ParkedOnlyFrameLayout( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ParkedOnlyFrameLayout( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + View rootView = LayoutInflater.from(getContext()).inflate(R.layout.driving_message_view, this); + mDrivingMessageView = rootView.findViewById(R.id.driving_message_view); + } + + @Override + protected void onDetachedFromWindow() { + if (mEventManager != null) { + mEventManager.unsubscribeEvent(this, EventType.CONSTRAINTS); + } + super.onDetachedFromWindow(); + } + + /** Set the template context used to start listening for uxr constraints. */ + public void setTemplateContext(TemplateContext templateContext) { + mTemplateContext = templateContext; + mEventManager = templateContext.getEventManager(); + + mEventManager.subscribeEvent(this, EventType.CONSTRAINTS, this::update); + + CarUiTextView drivingMessageText = mDrivingMessageView.findViewById(R.id.driving_message_text); + drivingMessageText.setText( + CarUiTextUtils.fromCharSequence( + templateContext, + templateContext.getString( + templateContext.getHostResourceIds().getDrivingStateMessageText()), + drivingMessageText.getMaxLines())); + update(); + } + + /** Get whether the content view is being hidden, and the driving message is being shown. */ + public boolean isLockedOut() { + return mIsLockedOut; + } + + private void update() { + boolean isRestricted = mTemplateContext.getConstraintsProvider().isConfigRestricted(); + if (isRestricted == mIsLockedOut) { + return; + } + mIsLockedOut = isRestricted; + + // Hide IME if ParkedOnlyFrameLayout is visible + if (mIsLockedOut) { + mTemplateContext.getInputManager().stopInput(); + } + + // Toggle visibility of all children views; the driving message will be shown if locked out, + // content views are shown otherwise. + for (int i = 0; i < this.getChildCount(); i++) { + View view = getChildAt(i); + if (view.getId() == mDrivingMessageView.getId()) { + view.setVisibility(mIsLockedOut ? VISIBLE : GONE); + } else { + view.setVisibility(mIsLockedOut ? GONE : VISIBLE); + } + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/PinSignInView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/PinSignInView.java new file mode 100644 index 0000000..1282365 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/PinSignInView.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.signin.PinSignInMethod; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.CarUiText; +import com.android.car.ui.widget.CarUiTextView; + +/** A view that displays {@link PinSignInMethod} UI. */ +public class PinSignInView extends FrameLayout { + private final int mMaxWidth; + + private CarUiTextView mPinTextView; + + public PinSignInView(Context context) { + this(context, null); + } + + public PinSignInView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public PinSignInView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings("ResourceType") + public PinSignInView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateSignInMethodViewMaxWidth, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mMaxWidth = ta.getDimensionPixelSize(0, 0); + ta.recycle(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int measuredWidth = MeasureSpec.getSize(widthMeasureSpec); + if (mMaxWidth > 0 && mMaxWidth < measuredWidth) { + int measureMode = MeasureSpec.getMode(widthMeasureSpec); + widthMeasureSpec = MeasureSpec.makeMeasureSpec(mMaxWidth, measureMode); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mPinTextView = findViewById(R.id.pin_text); + } + /** Returns the maximum height of mPinTextView */ + public int getMaxLines() { + return mPinTextView.getMaxLines(); + } + + /** Sets the PIN text. */ + public void setText(CarUiText pinText) { + mPinTextView.setText(pinText); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/QRCodeSignInView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/QRCodeSignInView.java new file mode 100644 index 0000000..39d2aa7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/QRCodeSignInView.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.templates.host.view.widgets.common; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.ImageView; +import androidx.car.app.model.signin.QRCodeSignInMethod; +import com.android.car.libraries.apphost.common.CarAppError; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.templates.host.R; +import com.google.zxing.WriterException; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; +import com.google.zxing.qrcode.encoder.ByteMatrix; +import com.google.zxing.qrcode.encoder.Encoder; +import com.google.zxing.qrcode.encoder.QRCode; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** A view that displays {@link QRCodeSignInMethod} UI. */ +public class QRCodeSignInView extends FrameLayout { + private ImageView mQRCodeView; + + public QRCodeSignInView(Context context) { + this(context, null); + } + + public QRCodeSignInView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public QRCodeSignInView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings("ResourceType") + public QRCodeSignInView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mQRCodeView = findViewById(R.id.qr_code_view); + } + + /** Sets the qr code. */ + public void setQRCodeSignInMethod( + TemplateContext templateContext, QRCodeSignInMethod qrCodeSignInMethod) { + setQRCode(templateContext, qrCodeSignInMethod.getUri().toString()); + } + + private void setQRCode(TemplateContext templateContext, String url) { + QRCode qrCode; + try { + qrCode = Encoder.encode(url, ErrorCorrectionLevel.H); + } catch (WriterException e) { + templateContext + .getErrorHandler() + .showError( + CarAppError.builder(templateContext.getCarAppPackageInfo().getComponentName()) + .setCause(e) + .build()); + return; + } + + BitmapDrawable drawable = new BitmapDrawable(getResources(), qrToBitmap(qrCode)); + drawable.setAntiAlias(false); + drawable.setFilterBitmap(false); + mQRCodeView.setImageDrawable(drawable); + } + + private Bitmap qrToBitmap(QRCode qrCode) { + ByteMatrix matrix = qrCode.getMatrix(); + int width = matrix.getWidth(); + int height = matrix.getHeight(); + int[] colors = new int[width * height]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + colors[y * width + x] = (matrix.get(x, y) != 0) ? Color.WHITE : Color.TRANSPARENT; + } + } + + return Bitmap.createBitmap(colors, 0, width, width, height, Bitmap.Config.ALPHA_8); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowAdapter.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowAdapter.java new file mode 100644 index 0000000..b040bc6 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowAdapter.java @@ -0,0 +1,1019 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static com.android.car.libraries.apphost.template.view.model.RowListWrapper.LIST_FLAGS_TEMPLATE_HAS_LARGE_IMAGE; +import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId; +import static java.lang.Math.min; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; +import android.text.Spannable; +import android.text.SpannableString; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.ImageView; +import android.widget.RadioButton; +import android.widget.Switch; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.car.app.model.Action; +import androidx.car.app.model.CarColor; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.ForegroundCarColorSpan; +import androidx.car.app.model.Place; +import androidx.car.app.model.PlaceMarker; +import androidx.car.app.model.Row; +import com.android.car.libraries.apphost.common.CarColorUtils; +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.RowConstraints; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.libraries.apphost.template.view.model.RowListWrapper; +import com.android.car.libraries.apphost.template.view.model.RowWrapper; +import com.android.car.libraries.apphost.template.view.model.SelectionGroup; +import com.android.car.libraries.apphost.view.common.ActionButtonListParams; +import com.android.car.libraries.apphost.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.RowHolder.RowListener; +import com.android.car.ui.CarUiText; +import com.android.car.ui.recyclerview.CarUiContentListItem; +import com.android.car.ui.recyclerview.CarUiContentListItem.IconType; +import com.android.car.ui.recyclerview.CarUiListItem; +import com.android.car.ui.recyclerview.CarUiListItemAdapter; +import com.android.car.ui.widget.CarUiTextView; +import java.util.ArrayList; +import java.util.List; + +/** Adapter for {@link ContentView} to display {@link CarUiListItem}s. */ +public class RowAdapter extends CarUiListItemAdapter { + /** + * Start id for non-Chassis items. This value should be higher than any view type in {@link + * CarUiListItemAdapter}. + */ + private static final int ROW_LIST_VIEW_TYPE_BASE = 1000; + + /** + * Empty payload used with {@link #notifyItemChanged(int, Object)} to selectively disable item + * change animations. + */ + private static final Object EMPTY_ITEM_CHANGED_PAYLOAD = new Object(); + + private static final int ROW_LIST_VIEW_TYPE_ACTION_BUTTON = ROW_LIST_VIEW_TYPE_BASE + 1; + private static final int ROW_LIST_VIEW_TYPE_SECTION_HEADER = ROW_LIST_VIEW_TYPE_BASE + 2; + private static final int ROW_LIST_VIEW_TYPE_ROW = ROW_LIST_VIEW_TYPE_BASE + 3; + private static final CarColor SELECTED_TEXT_COLOR = CarColor.BLUE; + private static final int TITLE_MAX_LINE_COUNT = 2; + private static final int ONE_BODY_MAX_LINE_COUNT = 2; + private static final int MULTI_BODY_MAX_LINE_COUNT = 1; + private static final int MAX_IMAGES_PER_TEXT_LINE = 2; + + @ColorInt private final int mDefaultIconTint; + @Nullable private final Drawable mPlaceholderDrawable; + @Nullable private final Drawable mFullRowChevronDrawable; + @Nullable private final Drawable mHalfRowChevronDrawable; + @ColorInt private final int mRowBackgroundColor; + + private RowListener mRowListener; + private List<RowHolder> mRowHolders; + private TemplateContext mTemplateContext; + private final MarkerFactory mMarkerFactory; + private final boolean mUseCompactLayout; + private final int mTitleTextSize; + private final int mSecondaryTextSize; + private final int mSectionHeaderTextSize; + + static RowAdapter create( + Context context, + List<CarUiListItem> items, + MarkerFactory markerFactory, + boolean useCompactLayout) { + return new RowAdapter(context, items, markerFactory, useCompactLayout); + } + + private RowAdapter( + Context context, + List<CarUiListItem> items, + MarkerFactory markerFactory, + boolean useCompactLayout) { + super(items, useCompactLayout); + + mUseCompactLayout = useCompactLayout; + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateRowDefaultIconTint, + R.attr.templateRowImagePlaceholder, + R.attr.templateFullRowChevronIcon, + R.attr.templateHalfRowChevronIcon, + R.attr.templateRowBackgroundColor, + }; + + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mDefaultIconTint = ta.getColor(0, 0); + mPlaceholderDrawable = ta.getDrawable(1); + mFullRowChevronDrawable = ta.getDrawable(2); + mHalfRowChevronDrawable = ta.getDrawable(3); + mRowBackgroundColor = ta.getColor(4, 0); + ta.recycle(); + + mTitleTextSize = getTextSizeFromAttribute(context, R.attr.templateRowTitleStyle); + mSecondaryTextSize = getTextSizeFromAttribute(context, R.attr.templateRowSecondaryTextStyle); + mSectionHeaderTextSize = + getTextSizeFromAttribute(context, R.attr.templateRowSectionHeaderStyle); + + mMarkerFactory = markerFactory; + } + + private static int getTextSizeFromAttribute(Context context, int attr) { + TypedArray ta = context.obtainStyledAttributes(new int[] {attr}); + int titleTextStyleResourceId = ta.getResourceId(0, 0); + ta.recycle(); + + ta = + context.obtainStyledAttributes( + titleTextStyleResourceId, new int[] {android.R.attr.textAppearance}); + int titleTextAppearanceResourceId = ta.getResourceId(0, 0); + ta.recycle(); + + ta = + context.obtainStyledAttributes( + titleTextAppearanceResourceId, new int[] {android.R.attr.textSize}); + int result = ta.getDimensionPixelSize(androidx.appcompat.R.styleable.TextAppearance_android_textSize, 0); + ta.recycle(); + + return result; + } + + public List<RowHolder> getRowHolders() { + return mRowHolders; + } + + /** Updates the rows of the adapter. */ + @SuppressWarnings("unchecked") + public void setRows( + TemplateContext templateContext, List<RowHolder> rowHolders, RowListener rowListener) { + int previousItemCount = mRowHolders == null ? 0 : mRowHolders.size(); + mTemplateContext = templateContext; + mRowHolders = rowHolders; + mRowListener = rowListener; + + List<CarUiListItem> items = new ArrayList<>(rowHolders.size()); + for (int index = 0; index < rowHolders.size(); index++) { + RowHolder holder = rowHolders.get(index); + RowWrapper rowWrapper = holder.getRowWrapper(); + CarUiListItem item = createCarUiListItem(templateContext, rowWrapper, index); + if (item == null) { + Log.e(LogTags.TEMPLATE, "Cannot create item for the row " + rowWrapper); + continue; + } + items.add(item); + } + + getItems().clear(); + ((List<CarUiListItem>) getItems()).addAll(items); + if (previousItemCount == items.size()) { + notifyItemRangeChanged(0, items.size(), EMPTY_ITEM_CHANGED_PAYLOAD); + } else { + notifyDataSetChanged(); + } + } + + /** Updates row at index {@code index} of the adapter. */ + @SuppressWarnings("unchecked") + public void updateRow(int index) { + if (index < 0 || index >= mRowHolders.size()) { + Log.e(LogTags.TEMPLATE, "Index out of bound " + index); + return; + } + RowWrapper rowWrapper = mRowHolders.get(index).getRowWrapper(); + CarUiListItem item = createCarUiListItem(mTemplateContext, rowWrapper, index); + if (item == null) { + Log.e(LogTags.TEMPLATE, "Cannot create item for the row " + rowWrapper); + return; + } + ((List<CarUiListItem>) getItems()).set(index, item); + // By passing a non-null payload to notifyItemChanged, we avoid ItemAnimator's onChange + // animation. This is needed when updating list items because otherwise the ViewHolder's + // contents flicker every time they are updated. + notifyItemChanged(index, EMPTY_ITEM_CHANGED_PAYLOAD); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == ROW_LIST_VIEW_TYPE_ACTION_BUTTON) { + return new ActionButtonListViewHolder( + LayoutInflater.from(parent.getContext()) + .inflate(R.layout.action_button_list_row, parent, false)); + } else if (viewType == ROW_LIST_VIEW_TYPE_SECTION_HEADER) { + return new SectionHeaderViewHolder( + LayoutInflater.from(parent.getContext()) + .inflate(R.layout.row_section_header_view, parent, false)); + } else if (viewType == ROW_LIST_VIEW_TYPE_ROW) { + if (mUseCompactLayout) { + return new ListItemViewHolder( + LayoutInflater.from(parent.getContext()) + .inflate( + R.layout.half_list_row_view, /* root= */ parent, /* attachToRoot= */ false), + mHalfRowChevronDrawable); + } else { + return new ListItemViewHolder( + LayoutInflater.from(parent.getContext()) + .inflate( + R.layout.full_list_row_view, /* root= */ parent, /* attachToRoot= */ false), + mFullRowChevronDrawable); + } + } else { + return super.onCreateViewHolder(parent, viewType); + } + } + + @Override + public int getItemViewType(int position) { + if (getItems().get(position) instanceof ActionButtonListItem) { + return ROW_LIST_VIEW_TYPE_ACTION_BUTTON; + } else if (getItems().get(position) instanceof SectionHeaderItem) { + return ROW_LIST_VIEW_TYPE_SECTION_HEADER; + } else if (getItems().get(position) instanceof CarUiContentListItem) { + return ROW_LIST_VIEW_TYPE_ROW; + } else { + return super.getItemViewType(position); + } + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + if (holder.getItemViewType() == ROW_LIST_VIEW_TYPE_ACTION_BUTTON) { + CarUiListItem item = getItems().get(position); + if (!(item instanceof ActionButtonListItem)) { + Log.e(LogTags.TEMPLATE, "Incorrect item view type for item " + item); + return; + } + if (!(holder instanceof ActionButtonListViewHolder)) { + Log.e(LogTags.TEMPLATE, "Incorrect view holder type for list item."); + return; + } + + ((ActionButtonListViewHolder) holder).bind((ActionButtonListItem) item); + } else if (holder.getItemViewType() == ROW_LIST_VIEW_TYPE_SECTION_HEADER) { + CarUiListItem item = getItems().get(position); + if (!(item instanceof SectionHeaderItem)) { + Log.e(LogTags.TEMPLATE, "Incorrect item view type for item " + item); + return; + } + if (!(holder instanceof SectionHeaderViewHolder)) { + Log.e(LogTags.TEMPLATE, "Incorrect view holder type for section header item."); + return; + } + + ((SectionHeaderViewHolder) holder).bind((SectionHeaderItem) item); + } else if (holder.getItemViewType() == ROW_LIST_VIEW_TYPE_ROW) { + CarUiListItem item = getItems().get(position); + if (!(item instanceof CarUiContentListItem)) { + Log.e(LogTags.TEMPLATE, "Incorrect item view type for item " + item); + return; + } + if (!(holder instanceof ListItemViewHolder)) { + Log.e(LogTags.TEMPLATE, "Incorrect view holder type for row item " + item); + return; + } + + // TODO(b/205602000): investigate switching to ConstraintLayout for this instead of + // calculating the margin in code. + RowHolder rowHolder = getRowHolders().get(position); + boolean hasTemplateImageBesidesRow = + (rowHolder.getRowWrapper().getListFlags() & LIST_FLAGS_TEMPLATE_HAS_LARGE_IMAGE) != 0; + ((ListItemViewHolder) holder).bind((CarUiContentListItem) item, hasTemplateImageBesidesRow); + } else { + super.onBindViewHolder(holder, position); + } + } + + /** Converts a {@link Row} to a {@link CarUiListItem}. */ + @Nullable + public CarUiListItem createCarUiListItem( + TemplateContext templateContext, RowWrapper rowWrapper, int index) { + if (rowWrapper.getRow() instanceof Row) { + return createRowCarUiListItem(templateContext, rowWrapper, index); + } else if (ActionListUtils.isActionList(rowWrapper.getRow())) { + return createActionsCarUiListItem(templateContext, rowWrapper); + } else { + Log.i(LogTags.TEMPLATE, "Unknown row type ${rowWrapper.row.javaClass.name}"); + return null; + } + } + + /** Converts the {@link Row} to a header */ + private SectionHeaderItem createCarUiHeaderListItem(TemplateContext templateContext, Row row) { + CarTextParams params = + new CarTextParams.Builder() + .setMaxImages(MAX_IMAGES_PER_TEXT_LINE) + .setImageBoundingBox(new Rect(0, 0, mSectionHeaderTextSize, mSectionHeaderTextSize)) + .build(); + return new SectionHeaderItem( + CarTextUtils.toCharSequenceOrEmpty(templateContext, row.getTitle(), params)); + } + + @Nullable + private CarUiListItem createActionsCarUiListItem( + TemplateContext templateContext, RowWrapper rowWrapper) { + List<Action> actions = ActionListUtils.getActionList(rowWrapper.getRow()); + int maxActions = rowWrapper.getRowConstraints().getMaxActionsExclusive(); + + return new RowAdapter.ActionButtonListItem( + templateContext, actions, maxActions, mRowBackgroundColor); + } + + private CarUiListItem createRowCarUiListItem( + TemplateContext templateContext, RowWrapper rowWrapper, int index) { + Row row = (Row) rowWrapper.getRow(); + + // If this row is a header, create a header item instead + if ((rowWrapper.getRowFlags() & RowWrapper.ROW_FLAG_SECTION_HEADER) != 0) { + return createCarUiHeaderListItem(templateContext, row); + } + + CarUiContentListItem.Action action = createAction(rowWrapper); + CarUiContentListItem item = new CarUiContentListItem(action); + boolean colorContrastCheckPassed = + checkColorContrast(templateContext, row, mRowBackgroundColor); + updateItemText( + item, templateContext, rowWrapper, index, /* allowColor= */ colorContrastCheckPassed); + + // Only update the item image if there is no place marker. + if (!updateItemPlaceMarker(item, templateContext, rowWrapper)) { + updateItemImage( + item, templateContext, rowWrapper, index, /* allowTint= */ colorContrastCheckPassed); + } + updateCheckedState(item, rowWrapper, index); + updateActivationState(item, rowWrapper, index); + updateClickListener(item, rowWrapper, index); + + item.setOnCheckedChangeListener( + (v, checked) -> { + if (mRowListener != null) { + mRowListener.onCheckedChange(index); + } + }); + + return item; + } + + /** Checks the color contrast between contents of the given row and the background color. */ + private static boolean checkColorContrast( + TemplateContext templateContext, Row row, @ColorInt int backgroundColor) { + // Only the secondary texts can be colored, so check them + for (CarText carText : row.getTexts()) { + if (!CarTextUtils.checkColorContrast(templateContext, carText, backgroundColor)) { + return false; + } + } + + CarIcon image = row.getImage(); + if (image == null) { + return true; + } + CarColor tint = image.getTint(); + if (tint == null) { + return true; + } + + return CarColorUtils.checkColorContrast(templateContext, tint, backgroundColor); + } + + /** + * Sets the click listener if the row is actionable. An actionable row is one that has a click + * delegate, selection state, or toggle. + */ + private void updateClickListener(CarUiContentListItem item, RowWrapper rowWrapper, int index) { + Row row = (Row) rowWrapper.getRow(); + + boolean isClickable = + row.getOnClickDelegate() != null + && rowWrapper.getRowConstraints().isOnClickListenerAllowed(); + boolean isSelectable = rowWrapper.getSelectionGroup() != null; + boolean isToggle = row.getToggle() != null; + if (isClickable || isSelectable || isToggle) { + item.setOnItemClickedListener( + (v) -> { + if (mRowListener != null) { + mRowListener.onRowClicked(index); + } + }); + } else { + item.setOnItemClickedListener(null); + } + } + + /** Updates the text fields of the item using properties of the {@link RowWrapper}. */ + private void updateItemText( + CarUiContentListItem item, + TemplateContext templateContext, + RowWrapper rowWrapper, + int index, + boolean allowColor) { + Row row = (Row) rowWrapper.getRow(); + RowConstraints constraints = rowWrapper.getRowConstraints(); + int listFlags = rowWrapper.getListFlags(); + + boolean renderTitleAsSecondaryText = + (listFlags & RowListWrapper.LIST_FLAGS_RENDER_TITLE_AS_SECONDARY) != 0; + + // Create a copy because the row model returns unmodifiable list. + List<CarText> texts = new ArrayList<>(row.getTexts()); + + CarText title = row.getTitle(); + if (title != null) { + CarTextParams titleParams = + new CarTextParams.Builder() + .setMaxImages(MAX_IMAGES_PER_TEXT_LINE) + .setImageBoundingBox(new Rect(0, 0, mTitleTextSize, mTitleTextSize)) + .build(); + CharSequence titleString = + CarTextUtils.toCharSequenceOrEmpty(templateContext, title, titleParams); + if (titleString.length() > 0) { + if (renderTitleAsSecondaryText) { + texts.add(0, title); + } else { + item.setTitle( + CarUiTextUtils.fromCarText( + templateContext, title, titleParams, TITLE_MAX_LINE_COUNT)); + } + } + } + + int lineCount = texts.size(); + int maxLineCount = min(constraints.getMaxTextLinesPerRow(), lineCount); + + if (maxLineCount < lineCount) { + Log.d( + LogTags.TEMPLATE, + "Number of secondary text lines " + lineCount + " over limit of " + maxLineCount); + } + + while (!texts.isEmpty() && texts.size() > maxLineCount) { + texts.remove(texts.size() - 1); + } + + // Add selected text to the body if available. + CarText selectedText = createSelectedText(rowWrapper, index); + if (selectedText != null) { + texts.add(selectedText); + } + + if (!texts.isEmpty()) { + List<CarUiText> bodyTexts = createCarUiTextList(templateContext, texts, allowColor); + item.setBody(bodyTexts); + } + } + + /** + * Creates a {@link CarText} representing {@code selectedText} for the given {@link RowWrapper}. + * + * <p>Returns {@code null} if selected text is not available or the row is not selected. + */ + @Nullable + private CarText createSelectedText(RowWrapper rowWrapper, int index) { + CarText selectedText = rowWrapper.getSelectedText(); + if (selectedText == null) { + return null; + } + + SelectionGroup selectionGroup = rowWrapper.getSelectionGroup(); + if (selectionGroup == null || !selectionGroup.isSelected(index)) { + return null; + } + + SpannableString spannableSelectedText = new SpannableString(selectedText.toCharSequence()); + int start = 0; + int end = spannableSelectedText.length(); + spannableSelectedText.setSpan( + ForegroundCarColorSpan.create(SELECTED_TEXT_COLOR), + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return new CarText.Builder(spannableSelectedText).build(); + } + + /** + * Updates the place marker image of the item using properties of the {@link RowWrapper}. + * + * <p>Returns true iff a place marker was found. + */ + private boolean updateItemPlaceMarker( + CarUiContentListItem item, TemplateContext templateContext, RowWrapper rowWrapper) { + Place place = rowWrapper.getMetadata().getPlace(); + if (place == null) { + return false; + } + + PlaceMarker marker = place.getMarker(); + if (marker == null) { + return false; + } + + Bitmap bitmap = mMarkerFactory.getContentForListMarker(templateContext, marker); + if (bitmap == null) { + return false; + } + + item.setPrimaryIconType(IconType.STANDARD); + item.setIcon(new BitmapDrawable(templateContext.getResources(), bitmap)); + return true; + } + + /** Updates the image of the item using properties of the {@link RowWrapper}. */ + private void updateItemImage( + CarUiContentListItem item, + TemplateContext templateContext, + RowWrapper rowWrapper, + int index, + boolean allowTint) { + Row row = (Row) rowWrapper.getRow(); + CarIcon image = row.getImage(); + if (image == null) { + return; + } + + CarUiContentListItem.IconType iconType = convertImageTypeToIconType(row.getRowImageType()); + if (iconType == null) { + Log.e(LogTags.TEMPLATE, "Unknown icon type for row " + row); + return; + } + item.setPrimaryIconType(iconType); + int iconSize = (int) getIconSize(iconType); + + ImageViewParams imageParams = + ImageViewParams.builder() + .setPlaceholderDrawable(mPlaceholderDrawable) + .setDefaultTint(mDefaultIconTint) + .setForceTinting(row.getRowImageType() == Row.IMAGE_TYPE_ICON) + .setIgnoreAppTint(!allowTint) + .setBackgroundColor(mRowBackgroundColor) + .setCarIconConstraints(rowWrapper.getRowConstraints().getCarIconConstraints()) + .build(); + ImageUtils.setImageTargetSrc( + templateContext, + row.getImage(), + drawable -> { + item.setIcon(drawable); + // By passing a non-null payload to notifyItemChanged, we avoid ItemAnimator's + // onChange animation. This is needed when updating list items because otherwise + // the ViewHolder's contents flicker every time they are updated. + notifyItemChanged(index, EMPTY_ITEM_CHANGED_PAYLOAD); + }, + imageParams, + iconSize, + iconSize); + } + + /** Updates the checked state of the item. */ + private void updateCheckedState(CarUiContentListItem item, RowWrapper rowWrapper, int index) { + SelectionGroup selectionGroup = rowWrapper.getSelectionGroup(); + boolean isSelected = selectionGroup != null && selectionGroup.getSelectedIndex() == index; + boolean hasRadioButton = item.getAction() == CarUiContentListItem.Action.RADIO_BUTTON; + + RowConstraints constraints = rowWrapper.getRowConstraints(); + boolean isToggleAllowed = constraints.isToggleAllowed(); + boolean hasToggle = item.getAction() == CarUiContentListItem.Action.SWITCH; + boolean isToggleChecked = rowWrapper.isToggleChecked(); + + item.setChecked( + (isSelected && hasRadioButton) || (isToggleAllowed && hasToggle && isToggleChecked)); + } + + /** Updates the activation state of the item. */ + private void updateActivationState(CarUiContentListItem item, RowWrapper rowWrapper, int index) { + SelectionGroup selectionGroup = rowWrapper.getSelectionGroup(); + boolean isSelected = selectionGroup != null && selectionGroup.getSelectedIndex() == index; + boolean useRadioButton = item.getAction() == CarUiContentListItem.Action.RADIO_BUTTON; + item.setActivated(isSelected && !useRadioButton && shouldHighlightSelectedRow(rowWrapper)); + } + + @Nullable + private static CarUiContentListItem.IconType convertImageTypeToIconType(int imageType) { + switch (imageType) { + case Row.IMAGE_TYPE_LARGE: + return IconType.CONTENT; + case Row.IMAGE_TYPE_SMALL: + case Row.IMAGE_TYPE_ICON: + return IconType.STANDARD; + default: + return null; + } + } + + private float getIconSize(CarUiContentListItem.IconType imageType) { + Resources res = mTemplateContext.getResources(); + switch (imageType) { + case CONTENT: + return res.getDimension(com.android.car.ui.R.dimen.car_ui_list_item_content_icon_width); + case STANDARD: + return res.getDimension(com.android.car.ui.R.dimen.car_ui_list_item_icon_size); + case AVATAR: + return res.getDimension(com.android.car.ui.R.dimen.car_ui_list_item_avatar_icon_width); + } + Log.e(LogTags.TEMPLATE, "Unknown imageType: " + imageType); + return res.getDimension(com.android.car.ui.R.dimen.car_ui_list_item_icon_size); + } + + /** Returns proper {@link CarUiContentListItem.Action} for a given {@link RowWrapper}. */ + private CarUiContentListItem.Action createAction(RowWrapper rowWrapper) { + if (!(rowWrapper.getRow() instanceof Row)) { + return CarUiContentListItem.Action.NONE; + } + + Row row = (Row) rowWrapper.getRow(); + if (row.isBrowsable()) { + return CarUiContentListItem.Action.CHEVRON; + } else if (row.getToggle() != null) { + return CarUiContentListItem.Action.SWITCH; + } else if (rowWrapper.getSelectionGroup() != null && shouldUseRadioButtons(rowWrapper)) { + return CarUiContentListItem.Action.RADIO_BUTTON; + } else { + return CarUiContentListItem.Action.NONE; + } + } + + /** + * Returns true if the flag for using radio buttons is enabled for the provided {@link + * RowWrapper}. + */ + private boolean shouldUseRadioButtons(RowWrapper rowWrapper) { + return (rowWrapper.getListFlags() & RowListWrapper.LIST_FLAGS_SELECTABLE_USE_RADIO_BUTTONS) + != 0; + } + + /** Returns true if the flag for highlighting the currently selected row is enabled */ + private boolean shouldHighlightSelectedRow(RowWrapper rowWrapper) { + return (rowWrapper.getListFlags() & RowListWrapper.LIST_FLAGS_SELECTABLE_HIGHLIGHT_ROW) != 0; + } + + /** Creates a list of {@link CarUiText} one for each given {@link CarText}. */ + private List<CarUiText> createCarUiTextList( + TemplateContext templateContext, List<CarText> carTexts, boolean allowColor) { + CarTextParams textParams = + CarTextParams.builder() + .setColorSpanConstraints( + allowColor ? CarColorConstraints.STANDARD_ONLY : CarColorConstraints.NO_COLOR) + .setMaxImages(MAX_IMAGES_PER_TEXT_LINE) + .setImageBoundingBox(new Rect(0, 0, mSecondaryTextSize, mSecondaryTextSize)) + .setBackgroundColor(mRowBackgroundColor) + .build(); + List<CarUiText> lines = new ArrayList<>(); + int maxLineCount = carTexts.size() > 1 ? MULTI_BODY_MAX_LINE_COUNT : ONE_BODY_MAX_LINE_COUNT; + for (CarText carText : carTexts) { + lines.add(CarUiTextUtils.fromCarText(templateContext, carText, textParams, maxLineCount)); + } + return lines; + } + + /** The {@link ViewHolder} for {@link ActionButtonListItem}. */ + static class ActionButtonListViewHolder extends RecyclerView.ViewHolder { + private final ActionButtonListView mActionButtonListView; + + ActionButtonListViewHolder(View view) { + super(view); + mActionButtonListView = requireViewByRefId(view, R.id.action_button_list_view); + } + + void bind(ActionButtonListItem item) { + mActionButtonListView.setActionList( + item.getTemplateContext(), + item.getActions(), + ActionButtonListParams.builder() + .setMaxActions(item.getMaxActions()) + .setOemReorderingAllowed(true) + .setOemColorOverrideAllowed(true) + .setSurroundingColor(item.getSurroundingColor()) + .build()); + } + } + + /** The {@link ViewHolder} for {@link SectionHeaderItem}. */ + static class SectionHeaderViewHolder extends RecyclerView.ViewHolder { + SectionHeaderViewHolder(View view) { + super(view); + } + + void bind(SectionHeaderItem item) { + CarUiTextView sectionHeaderView = (CarUiTextView) itemView; + sectionHeaderView.setText(item.getText()); + } + } + + /** View model for an {@link ActionButtonListView}. */ + public static class ActionButtonListItem extends CarUiListItem { + private final TemplateContext mTemplateContext; + private final List<Action> mActionList; + private final int mMaxActions; + @ColorInt private final int mSurroundingColor; + + ActionButtonListItem( + TemplateContext templateContext, + List<Action> actionList, + int maxActions, + @ColorInt int surroundingColor) { + mActionList = actionList; + mTemplateContext = templateContext; + mMaxActions = maxActions; + mSurroundingColor = surroundingColor; + } + + /** Returns a list of {@link Action}s */ + List<Action> getActions() { + return mActionList; + } + + /** Returns the associated {@link TemplateContext} */ + TemplateContext getTemplateContext() { + return mTemplateContext; + } + + /** Returns the maximum number of actions allowed. */ + int getMaxActions() { + return mMaxActions; + } + + /** Returns the color of the surrounding region around the action button list. */ + @ColorInt + int getSurroundingColor() { + return mSurroundingColor; + } + } + + /** View model for a section header. */ + public static class SectionHeaderItem extends CarUiListItem { + private final CharSequence mText; + + SectionHeaderItem(CharSequence text) { + mText = text; + } + + CharSequence getText() { + return mText; + } + } + + /** Holds views of {@link CarUiContentListItem}. */ + static class ListItemViewHolder extends RecyclerView.ViewHolder { + + final CarUiTextView mTitle; + final CarUiTextView mBody; + final ImageView mIcon; + final ImageView mContentIcon; + final ImageView mAvatarIcon; + final ViewGroup mIconContainer; + final ViewGroup mActionContainer; + final Switch mSwitch; + final CheckBox mCheckBox; + final RadioButton mRadioButton; + final ImageView mSupplementalIcon; + final View mTouchInterceptor; + final View mReducedTouchInterceptor; + final View mActionContainerTouchInterceptor; + @Nullable final Drawable mChevronDrawable; + @Nullable final View mLargeImageSpacer; + + ListItemViewHolder(@NonNull View itemView, @Nullable Drawable chevronDrawable) { + super(itemView); + mTitle = requireViewByRefId(itemView, R.id.car_ui_list_item_title); + mBody = requireViewByRefId(itemView, R.id.car_ui_list_item_body); + mIcon = requireViewByRefId(itemView, R.id.car_ui_list_item_icon); + mContentIcon = requireViewByRefId(itemView, R.id.car_ui_list_item_content_icon); + mAvatarIcon = requireViewByRefId(itemView, R.id.car_ui_list_item_avatar_icon); + mIconContainer = requireViewByRefId(itemView, R.id.car_ui_list_item_icon_container); + mActionContainer = requireViewByRefId(itemView, R.id.car_ui_list_item_action_container); + mSwitch = requireViewByRefId(itemView, R.id.car_ui_list_item_switch_widget); + mCheckBox = requireViewByRefId(itemView, R.id.car_ui_list_item_checkbox_widget); + mRadioButton = requireViewByRefId(itemView, R.id.car_ui_list_item_radio_button_widget); + mSupplementalIcon = requireViewByRefId(itemView, R.id.car_ui_list_item_supplemental_icon); + mReducedTouchInterceptor = + requireViewByRefId(itemView, R.id.car_ui_list_item_reduced_touch_interceptor); + mTouchInterceptor = requireViewByRefId(itemView, R.id.car_ui_list_item_touch_interceptor); + mActionContainerTouchInterceptor = + requireViewByRefId(itemView, R.id.car_ui_list_item_action_container_touch_interceptor); + mChevronDrawable = chevronDrawable; + mLargeImageSpacer = itemView.findViewById(R.id.large_image_spacer); + } + + void bind(@NonNull CarUiContentListItem item, boolean hasTemplateImageBesidesRow) { + CarUiText title = item.getTitle(); + if (title != null) { + mTitle.setText(title); + mTitle.setVisibility(View.VISIBLE); + } else { + mTitle.setVisibility(View.GONE); + } + + List<CarUiText> body = item.getBody(); + if (body != null) { + mBody.setText(body); + mBody.setVisibility(View.VISIBLE); + } else { + mBody.setVisibility(View.GONE); + } + + mIcon.setVisibility(View.GONE); + mContentIcon.setVisibility(View.GONE); + mAvatarIcon.setVisibility(View.GONE); + + Drawable icon = item.getIcon(); + if (icon != null) { + mIconContainer.setVisibility(View.VISIBLE); + + switch (item.getPrimaryIconType()) { + case CONTENT: + mContentIcon.setVisibility(View.VISIBLE); + mContentIcon.setImageDrawable(icon); + break; + case STANDARD: + mIcon.setVisibility(View.VISIBLE); + mIcon.setImageDrawable(icon); + break; + case AVATAR: + mAvatarIcon.setVisibility(View.VISIBLE); + mAvatarIcon.setImageDrawable(icon); + mAvatarIcon.setClipToOutline(true); + break; + } + } else { + mIconContainer.setVisibility(View.GONE); + } + + mSwitch.setVisibility(View.GONE); + mCheckBox.setVisibility(View.GONE); + mRadioButton.setVisibility(View.GONE); + mSupplementalIcon.setVisibility(View.GONE); + + CarUiContentListItem.OnClickListener itemOnClickListener = item.getOnClickListener(); + + switch (item.getAction()) { + case NONE: + mActionContainer.setVisibility(View.GONE); + + // Display ripple effects across entire item when clicked by using full-sized + // touch interceptor. + mTouchInterceptor.setVisibility(View.VISIBLE); + mTouchInterceptor.setOnClickListener( + v -> { + if (itemOnClickListener != null) { + itemOnClickListener.onClick(item); + } + }); + mTouchInterceptor.setClickable(itemOnClickListener != null); + mReducedTouchInterceptor.setVisibility(View.GONE); + mActionContainerTouchInterceptor.setVisibility(View.GONE); + break; + case SWITCH: + bindCompoundButton(item, mSwitch, itemOnClickListener); + break; + case CHECK_BOX: + bindCompoundButton(item, mCheckBox, itemOnClickListener); + break; + case RADIO_BUTTON: + bindCompoundButton(item, mRadioButton, itemOnClickListener); + break; + case CHEVRON: + mSupplementalIcon.setVisibility(View.VISIBLE); + mSupplementalIcon.setImageDrawable(mChevronDrawable); + mActionContainer.setVisibility(View.VISIBLE); + mTouchInterceptor.setVisibility(View.VISIBLE); + mTouchInterceptor.setOnClickListener( + v -> { + if (itemOnClickListener != null) { + itemOnClickListener.onClick(item); + } + }); + mTouchInterceptor.setClickable(itemOnClickListener != null); + mReducedTouchInterceptor.setVisibility(View.GONE); + mActionContainerTouchInterceptor.setVisibility(View.GONE); + break; + case ICON: + mSupplementalIcon.setVisibility(View.VISIBLE); + mSupplementalIcon.setImageDrawable(item.getSupplementalIcon()); + + mActionContainer.setVisibility(View.VISIBLE); + + // If the icon has a click listener, use a reduced touch interceptor to create + // two distinct touch area; the action container and the remainder of the list + // item. Each touch area will have its own ripple effect. If the icon has no + // click listener, it shouldn't be clickable. + if (item.getSupplementalIconOnClickListener() == null) { + mTouchInterceptor.setVisibility(View.VISIBLE); + mTouchInterceptor.setOnClickListener( + v -> { + if (itemOnClickListener != null) { + itemOnClickListener.onClick(item); + } + }); + mTouchInterceptor.setClickable(itemOnClickListener != null); + mReducedTouchInterceptor.setVisibility(View.GONE); + mActionContainerTouchInterceptor.setVisibility(View.GONE); + } else { + mReducedTouchInterceptor.setVisibility(View.VISIBLE); + mReducedTouchInterceptor.setOnClickListener( + v -> { + if (itemOnClickListener != null) { + itemOnClickListener.onClick(item); + } + }); + mReducedTouchInterceptor.setClickable(itemOnClickListener != null); + mActionContainerTouchInterceptor.setVisibility(View.VISIBLE); + mActionContainerTouchInterceptor.setOnClickListener( + (container) -> { + CarUiContentListItem.OnClickListener listener = + item.getSupplementalIconOnClickListener(); + if (listener != null) { + listener.onClick(item); + } + }); + mActionContainerTouchInterceptor.setClickable( + item.getSupplementalIconOnClickListener() != null); + mTouchInterceptor.setVisibility(View.GONE); + } + break; + } + + // Sets the right margin for the row to account for the space needed for the large image. + View spacer = mLargeImageSpacer; + if (spacer != null) { + spacer.setVisibility(hasTemplateImageBesidesRow ? View.VISIBLE : View.GONE); + } + + itemView.setActivated(item.isActivated()); + setEnabled(itemView, item.isEnabled()); + } + + void setEnabled(View view, boolean enabled) { + view.setEnabled(enabled); + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + + for (int i = 0; i < group.getChildCount(); i++) { + setEnabled(group.getChildAt(i), enabled); + } + } + } + + void bindCompoundButton( + @NonNull CarUiContentListItem item, + @NonNull CompoundButton compoundButton, + @Nullable CarUiContentListItem.OnClickListener itemOnClickListener) { + compoundButton.setVisibility(View.VISIBLE); + compoundButton.setOnCheckedChangeListener(null); + compoundButton.setChecked(item.isChecked()); + compoundButton.setOnCheckedChangeListener( + (buttonView, isChecked) -> item.setChecked(isChecked)); + + // Clicks anywhere on the item should toggle the checkbox state. Use full touch + // interceptor. + mTouchInterceptor.setVisibility(View.VISIBLE); + mTouchInterceptor.setOnClickListener( + v -> { + compoundButton.toggle(); + if (itemOnClickListener != null) { + itemOnClickListener.onClick(item); + } + }); + // Compound button list items should always be clickable + mTouchInterceptor.setClickable(true); + mReducedTouchInterceptor.setVisibility(View.GONE); + mActionContainerTouchInterceptor.setVisibility(View.GONE); + + mActionContainer.setVisibility(View.VISIBLE); + mActionContainer.setClickable(false); + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowHolder.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowHolder.java new file mode 100644 index 0000000..8fcf34c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowHolder.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.templates.host.view.widgets.common; + +import static com.android.car.libraries.apphost.template.view.model.RowWrapper.ROW_FLAG_SECTION_HEADER; +import static com.android.car.libraries.apphost.template.view.model.RowWrapper.ROW_FLAG_SHOW_DIVIDERS; +import static com.android.car.libraries.templates.host.view.widgets.common.ActionListUtils.isActionList; + +import com.android.car.libraries.apphost.common.TemplateContext; +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; +import com.google.common.collect.ImmutableList; +import java.util.List; + +/** A holder of a row instance with its associated metadata. */ +public class RowHolder { + /** Listener of events related to a {@link RowHolder} instance. */ + public interface RowListener { + /** + * Notifies that a row has been selected. + * + * @param index index of the row in the list it belongs to. + */ + void onRowClicked(int index); + + /** + * Notifies that a row's check state has been changed. + * + * @param index index of the row in the list it belongs to. + */ + void onCheckedChange(int index); + + /** Notifies that a row's focus has changed. */ + void onRowFocusChanged(int index, boolean hasFocus); + } + + private final RowWrapper mRow; + + @Override + public String toString() { + return mRow.toString(); + } + + /** Creates a {@link RowHolder} from a row object. */ + public static RowHolder create(RowWrapper row) { + return new RowHolder(row); + } + + /** Returns a list of {@link RowHolder} instances from the given rows. */ + static ImmutableList<RowHolder> createHolders( + TemplateContext templateContext, List<RowWrapper> rows, RowListConstraints constraints) { + if (rows.isEmpty()) { + return ImmutableList.of(); + } + + ImmutableList.Builder<RowHolder> listBuilder = ImmutableList.builder(); + + int maxRowCount = + templateContext.getConstraintsProvider().getContentLimit(constraints.getListContentType()); + int nonHeaderRowCount = 0; + + // Cache the last seen header row, and only add it if there is a non-header row underneath + // it. + // We don't support consecutive header rows. + RowWrapper lastHeaderRow = null; + for (int i = 0; i < rows.size(); ++i) { + RowWrapper rowWrapper = rows.get(i); + Object rowObj = rowWrapper.getRow(); + + if (isActionList(rowObj)) { + // Special case for an action list which is only for the first row in PaneTemplate. + listBuilder.add(RowHolder.create(rowWrapper)); + } else { + // Ensure we only count the actual rows against the row limit. + boolean isSectionHeader = (rowWrapper.getRowFlags() & ROW_FLAG_SECTION_HEADER) != 0; + if (!isSectionHeader) { + nonHeaderRowCount++; + if (nonHeaderRowCount > maxRowCount) { + L.w( + LogTags.TEMPLATE, + "Row count exceeds the supported maximum of %d, will drop the" + + " remaining excess rows", + maxRowCount); + break; + } + + if (lastHeaderRow != null) { + listBuilder.add(RowHolder.create(lastHeaderRow)); + lastHeaderRow = null; + } + listBuilder.add(RowHolder.create(rowWrapper)); + } else { + if (lastHeaderRow != null) { + L.w( + LogTags.TEMPLATE, + "Consecutive header rows detected and is not supported, only the" + + " last one will be used"); + } + + lastHeaderRow = rowWrapper; + } + } + } + + return listBuilder.build(); + } + + public RowConstraints getConstraints() { + return mRow.getRowConstraints(); + } + + public RowWrapper getRowWrapper() { + return mRow; + } + + boolean isSectionHeader() { + return 0 != (mRow.getRowFlags() & ROW_FLAG_SECTION_HEADER); + } + + boolean showDividers() { + return 0 != (mRow.getRowFlags() & ROW_FLAG_SHOW_DIVIDERS); + } + + private RowHolder(RowWrapper row) { + mRow = row; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowListView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowListView.java new file mode 100644 index 0000000..6f393df --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowListView.java @@ -0,0 +1,577 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static java.lang.Math.min; +import static java.util.Objects.requireNonNull; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Rect; +import android.os.Build; +import android.os.Build.VERSION_CODES; + +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.OnCheckedChangeDelegate; +import androidx.car.app.model.OnClickDelegate; +import androidx.car.app.model.OnItemVisibilityChangedDelegate; +import androidx.car.app.model.Place; +import androidx.car.app.model.Row; +import androidx.car.app.model.Toggle; +import androidx.recyclerview.widget.DefaultItemAnimator; +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; +import androidx.recyclerview.widget.RecyclerView.ItemAnimator; +import com.android.car.libraries.apphost.common.LocationMediator; +import com.android.car.libraries.apphost.common.TemplateContext; +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.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.template.view.model.RowListWrapper; +import com.android.car.libraries.apphost.template.view.model.RowWrapper; +import com.android.car.libraries.apphost.template.view.model.SelectionGroup; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.MarkerFactory.MarkerAppearance; +import com.android.car.libraries.templates.host.view.widgets.common.RowHolder.RowListener; +import com.android.car.ui.recyclerview.CarUiContentListItem; +import com.android.car.ui.recyclerview.CarUiListItem; +import com.android.car.ui.recyclerview.CarUiRecyclerView; +import com.android.car.ui.recyclerview.CarUiRecyclerView.OnScrollListener; +import com.android.car.ui.widget.CarUiTextView; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.List; + +/** + * A view that can render a list of {@link androidx.car.app.model.Row} (wrapped inside a {@link + * RowListWrapper}. + */ +public class RowListView extends FrameLayout { + private final AdapterDataObserver mAdapterDataObserver = + new AdapterDataObserver() { + // call to update() not allowed on the given receiver. + @SuppressWarnings("nullness:method.invocation") + @Override + public void onChanged() { + super.onChanged(); + update(); + } + + @SuppressWarnings("nullness:method.invocation") + @Override + public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) { + super.onItemRangeChanged(positionStart, itemCount, payload); + update(); + } + + @SuppressWarnings("nullness:method.invocation") + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + super.onItemRangeMoved(fromPosition, toPosition, itemCount); + update(); + } + + @SuppressWarnings("nullness:method.invocation") + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + super.onItemRangeInserted(positionStart, itemCount); + update(); + } + }; + + private MarkerFactory mMarkerFactory; + private RowAdapter mListAdapter; + private RowVisibilityObserver mRowVisibilityObserver; + private ViewGroup mProgressContainer; + private CarUiTextView mEmptyListTextView; + private CarUiRecyclerView mListView; + private RowListWrapper mRowList; + private boolean mIsLoading; + private final boolean mUseCompactRowLayout; + + // This is only present in the full list view layout. + @Nullable private ViewGroup mLargeImageContainer; + + // This is only present in the full list view layout. + @Nullable private CarImageView mLargeImageView; + private final float mLargeImageWidthRatio; + private final int mLargeImageMaxWidth; + private final float mLargeImageAspectRatio; + private final int mRowListAndImagePadding; + private final int mLargeImageEndPadding; + private final int mLargeImageTopMargin; + private boolean hasLaidOutLargeImage; + + public RowListView(Context context) { + this(context, null); + } + + public RowListView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public RowListView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + @SuppressWarnings({"ResourceType", "nullness:method.invocation", "nullness:argument"}) + public RowListView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateRowListToLargeImageRatio, + R.attr.templateRowListLargeImageContainerMaxWidth, + R.attr.templateRowListLargeImageAspectRatio, + R.attr.templateRowListAndImagePadding, + R.attr.templateFullRowEndPadding, + }; + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mLargeImageWidthRatio = ta.getFloat(0, 0.f); + int largeImageContainerMaxWidth = ta.getDimensionPixelSize(1, 0); + mLargeImageAspectRatio = ta.getFloat(2, 0.f); + mRowListAndImagePadding = ta.getDimensionPixelSize(3, 0); + // The end padding should be consistent with what was used for the row item's end padding. + mLargeImageEndPadding = ta.getDimensionPixelSize(4, 0); + ta.recycle(); + mLargeImageMaxWidth = + largeImageContainerMaxWidth - (mRowListAndImagePadding + mLargeImageEndPadding); + mLargeImageTopMargin = context.getResources().getDimensionPixelSize(R.dimen.template_padding_2); + + ta = context.obtainStyledAttributes(attrs, R.styleable.RowListView, defStyleAttr, defStyleRes); + mUseCompactRowLayout = ta.getBoolean(R.styleable.RowListView_listUseCompactRowLayout, false); + ta.recycle(); + + mMarkerFactory = + MarkerFactory.create( + context, new MarkerAppearance(context, attrs, defStyleAttr, defStyleAttr)); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mProgressContainer = findViewById(R.id.progress_container); + mEmptyListTextView = findViewById(R.id.list_no_items_text); + mListView = findViewById(R.id.list_view); + mRowVisibilityObserver = RowVisibilityObserver.create(requireNonNull(mListView)); + mListAdapter = + RowAdapter.create(getContext(), new ArrayList<>(), mMarkerFactory, mUseCompactRowLayout); + mListView.setAdapter(mListAdapter); + + // TODO(b/210167386): setItemAnimator will deprecate for sc+. We can still use the code below to + // control the itemAnimator for qt and rvc + if (Build.VERSION.SDK_INT <= VERSION_CODES.R) { + ItemAnimator itemAnimatorNoDuration = new DefaultItemAnimator(); + itemAnimatorNoDuration.setAddDuration(0); + itemAnimatorNoDuration.setChangeDuration(0); + itemAnimatorNoDuration.setMoveDuration(0); + itemAnimatorNoDuration.setRemoveDuration(0); + mListView.setItemAnimator(itemAnimatorNoDuration); + } + + mListAdapter.registerAdapterDataObserver(mAdapterDataObserver); + + ViewGroup imageViewContainer = findViewById(R.id.large_image_container); + if (imageViewContainer != null) { + mLargeImageContainer = imageViewContainer; + mLargeImageView = imageViewContainer.findViewById(R.id.large_image); + + // Synchronize the scrolling of the list with the vertical offset of the image. + mListView.addOnScrollListener( + new OnScrollListener() { + @Override + public void onScrolled(CarUiRecyclerView recyclerView, int dx, int dy) { + FrameLayout.LayoutParams layoutParams = + (FrameLayout.LayoutParams) imageViewContainer.getLayoutParams(); + layoutParams.topMargin -= dy; + imageViewContainer.setLayoutParams(layoutParams); + } + + @Override + public void onScrollStateChanged(CarUiRecyclerView recyclerView, int newState) { + // No-op. + } + }); + } + + update(); + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public CarUiRecyclerView getRecyclerView() { + return mListView; + } + + @Nullable + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public RowAdapter getAdapter() { + return mListAdapter; + } + + void setRowList(TemplateContext templateContext, RowListWrapper rowList) { + mRowList = rowList; + + boolean isLoading = rowList.isLoading(); + if (mIsLoading != isLoading) { + // Trigger a visibility update if the loading state has changed. + mIsLoading = isLoading; + update(); + + if (mIsLoading) { + // Scroll to the top so we will show the first row when the loading finishes. + mListView.scrollToPosition(0); + } + } + + CarText emptyListCarText = rowList.getEmptyListText(); + CharSequence emptyText; + if (emptyListCarText != null && !emptyListCarText.isEmpty()) { + mEmptyListTextView.setText( + CarUiTextUtils.fromCarText( + templateContext, emptyListCarText, mEmptyListTextView.getMaxLines())); + } else { + emptyText = + templateContext.getText( + templateContext.getHostResourceIds().getTemplateListNoItemsText()); + mEmptyListTextView.setText( + CarUiTextUtils.fromCharSequence( + templateContext, emptyText, mEmptyListTextView.getMaxLines())); + } + + CarIcon paneImage = rowList.getImage(); + ViewGroup imageViewContainer = mLargeImageContainer; + if (imageViewContainer != null) { + if (paneImage != null) { + ImageUtils.setImageSrc( + templateContext, paneImage, requireNonNull(mLargeImageView), ImageViewParams.DEFAULT); + } + } + + RowListConstraints constraints = rowList.getRowListConstraints(); + List<RowHolder> rowHolders = + RowHolder.createHolders(templateContext, rowList.getRowWrappers(), constraints); + + mRowVisibilityObserver.setOnItemVisibilityChangedListener( + (startIndexInclusive, endIndexExclusive) -> { + OnItemVisibilityChangedDelegate onItemVisibilityChangedDelegate = + rowList.getOnItemVisibilityChangedDelegate(); + if (onItemVisibilityChangedDelegate != null) { + templateContext + .getAppDispatcher() + .dispatchItemVisibilityChanged( + onItemVisibilityChangedDelegate, startIndexInclusive, endIndexExclusive); + } + publishMetadata( + templateContext, rowList.getRowWrappers(), startIndexInclusive, endIndexExclusive); + }); + + mListAdapter.setRows( + templateContext, + rowHolders, + new RowListener() { + @Override + public void onRowClicked(int index) { + TelemetryHandler telemetry = templateContext.getTelemetryHandler(); + telemetry.logCarAppTelemetry( + TelemetryEvent.newBuilder(TelemetryEvent.UiAction.ROW_CLICKED).setPosition(index)); + + onRowSelected(templateContext, index, /* clicked= */ true); + } + + @Override + public void onCheckedChange(int index) { + TelemetryHandler telemetry = templateContext.getTelemetryHandler(); + telemetry.logCarAppTelemetry( + TelemetryEvent.newBuilder(TelemetryEvent.UiAction.ROW_CLICKED).setPosition(index)); + + maybeSwitchToggleState(templateContext, index); + } + + @Override + public void onRowFocusChanged(int index, boolean hasFocus) { + RowListView.this.onRowFocused(templateContext, index, hasFocus); + } + }); + + if (!rowList.isRefresh()) { + mListView.scrollToPosition(0); + } + + ViewUtils.logCarAppTelemetry(templateContext, UiAction.LIST_SIZE, rowHolders.size()); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + // The layout of the large image container is dependent on the final size and paddings of the + // list items insider the RecyclerView. Here we obtain the bounds of the first item in the + // RecyclerView and lines up the image container based on that. + // + // We only need to do this once at the beginning to place the image at the right position. + // Subsequent synchronization is handled via the OnScrollListener. + ViewGroup imageViewContainer = mLargeImageContainer; + if (imageViewContainer == null || hasLaidOutLargeImage) { + return; + } + + int firstActualRowIndex = -1; + List<? extends CarUiListItem> items = mListAdapter.getItems(); + // Find the first item that is a CarUiContentListItem which is used for an actual Row. + // Action button lists and section headers use different CarUiListItem types. + for (int i = 0; i < items.size(); i++) { + CarUiListItem item = items.get(i); + if (item instanceof CarUiContentListItem) { + firstActualRowIndex = i; + break; + } + } + + if (firstActualRowIndex == -1) { + return; + } + + View itemView = mListView.getRecyclerViewChildAt(firstActualRowIndex); + if (itemView != null) { + // Get the item view bounds relative to the RowListView container, and use that + // to determine the offset for the image view. + Rect itemViewBound = new Rect(); + itemView.getDrawingRect(itemViewBound); + RowListView.this.offsetDescendantRectToMyCoords(itemView, itemViewBound); + + // Sets the bounding box based on desired width and aspect ratio. + int imageWidth = + min( + mLargeImageMaxWidth, + // Image width is a ratio of the total container width, accounting for the + // padding we want from the row and the edge of the screen. + (int) (mLargeImageWidthRatio * itemViewBound.width()) + - (mRowListAndImagePadding + mLargeImageEndPadding)); + + FrameLayout.LayoutParams imageParams = + (FrameLayout.LayoutParams) imageViewContainer.getLayoutParams(); + imageParams.topMargin = itemViewBound.top + mLargeImageTopMargin; + imageParams.setMarginEnd( + RowListView.this.getRight() - itemViewBound.right + mLargeImageEndPadding); + imageParams.width = imageWidth; + imageParams.height = (int) (imageWidth * mLargeImageAspectRatio); + imageViewContainer.setLayoutParams(imageParams); + + hasLaidOutLargeImage = true; + } + } + + private void onRowSelected(TemplateContext templateContext, int newIndex, boolean clicked) { + RowWrapper rowWrapper = getRowWrapperIfValid(newIndex); + if (rowWrapper == null) { + return; + } + + if (rowWrapper.getRow() instanceof Row) { + Row row = (Row) rowWrapper.getRow(); + final OnClickDelegate onClickDelegate = row.getOnClickDelegate(); + if (onClickDelegate != null) { + templateContext.getAppDispatcher().dispatchClick(onClickDelegate); + } + } + + SelectionGroup selectionGroup = rowWrapper.getSelectionGroup(); + + // If the row belongs to a selection group, change the selection in the group if necessary. + // This is done here in the host without having to do a round-trip to the client to change + // the + // model and re-fresh the entire list, which is much faster and more convenient for apps. + if (selectionGroup != null) { + int currentSelectionIndex = selectionGroup.getSelectedIndex(); + + // If the selected index changed, deselect the previously selected row, and select the + // new + // one. + boolean isRowPreviouslySelected = currentSelectionIndex == newIndex; + if (!isRowPreviouslySelected) { + // Store the new selection. This is important also in case the rows get re-created + // during recycling so that they maintain the proper state. + selectionGroup.setSelectedIndex(newIndex); + + mListAdapter.updateRow(currentSelectionIndex); + mListAdapter.updateRow(newIndex); + + boolean shouldScrollToSelectedRow = + (mRowList.getListFlags() & RowListWrapper.LIST_FLAGS_SELECTABLE_SCROLL_TO_ROW) != 0; + if (shouldScrollToSelectedRow) { + // Post to the main thread so that the scroll happens after the UI changes for + // the selected state is completed. + post(() -> mListView.smoothScrollToPosition(newIndex)); + } + } + + // Dispatch the selection callbacks. + // Note the selection event is dispatched regardless of selection index actually + // changing. + templateContext + .getAppDispatcher() + .dispatchSelected( + selectionGroup.getOnSelectedDelegate(), selectionGroup.getRelativeIndex(newIndex)); + if (isRowPreviouslySelected && clicked) { + Runnable runnable = mRowList.getRepeatedSelectionCallback(); + if (runnable != null) { + runnable.run(); + } + } + } + } + + private void onRowFocused(TemplateContext templateContext, int index, boolean hasFocus) { + // Select the row if moving the focus should change the selection, and we have a new focus. + boolean focusChangeSelection = + (mRowList.getListFlags() & RowListWrapper.LIST_FLAGS_SELECTABLE_FOCUS_SELECT_ROW) != 0; + if (focusChangeSelection && hasFocus) { + onRowSelected(templateContext, index, /* clicked= */ false); + } + } + + /** Switches the toggle state of a row if it does contain a toggle. */ + private void maybeSwitchToggleState(TemplateContext templateContext, int index) { + RowWrapper rowWrapper = getRowWrapperIfValid(index); + if (rowWrapper == null) { + return; + } + + Object rowObj = rowWrapper.getRow(); + + // Only rows can contain toggles. + if (rowObj instanceof Row) { + Row row = (Row) rowObj; + + // Does the row contain a toggle ? if so, switch its state. + Toggle toggle = row.getToggle(); + if (toggle != null) { + rowWrapper.switchToggleState(); + // Dispatch the checked change callback to the app. + OnCheckedChangeDelegate delegate = toggle.getOnCheckedChangeDelegate(); + templateContext + .getAppDispatcher() + .dispatchCheckedChanged(delegate, rowWrapper.isToggleChecked()); + } + } + } + + private void update() { + boolean isLoading = mIsLoading; + ViewGroup largeImageContainer = mLargeImageContainer; + if (isLoading) { + mProgressContainer.setVisibility(VISIBLE); + + // Mark the content views as invisible so that the size of the container remains the + // same while the progress bar is showing. + mEmptyListTextView.setVisibility(INVISIBLE); + mListView.setVisibility(INVISIBLE); + + if (largeImageContainer != null) { + largeImageContainer.setVisibility(INVISIBLE); + } + + return; + } + + mProgressContainer.setVisibility(GONE); + + // If the list is empty, hide it and display a message instead. + boolean isEmpty = mListAdapter.getItemCount() == 0; + if (isEmpty) { + mEmptyListTextView.setVisibility(VISIBLE); + mListView.setVisibility(GONE); + + if (largeImageContainer != null) { + largeImageContainer.setVisibility(GONE); + } + + mEmptyListTextView.setFocusable(true); + } else { + mEmptyListTextView.setVisibility(GONE); + mListView.setVisibility(VISIBLE); + + if (largeImageContainer != null) { + largeImageContainer.setVisibility(mRowList.getImage() != null ? VISIBLE : GONE); + } + } + } + + /** Publish any non-null {@link Place}s from the list of {@link RowWrapper}. */ + private void publishMetadata( + TemplateContext templateContext, + List<RowWrapper> rowWrappers, + int startIndexInclusive, + int endIndexExclusive) { + if (templateContext == null) { + L.e(LogTags.TEMPLATE, "TemplateContext is null"); + return; + } + + // Return if the range is empty. + if (startIndexInclusive < 0) { + return; + } + + if (endIndexExclusive > rowWrappers.size()) { + L.e(LogTags.TEMPLATE, "Index out of bound: (%d > %d)", endIndexExclusive, rowWrappers.size()); + return; + } + + ImmutableList.Builder<Place> builder = new ImmutableList.Builder<>(); + for (int index = startIndexInclusive; index < endIndexExclusive; index++) { + RowWrapper rowWrapper = rowWrappers.get(index); + Place place = rowWrapper.getMetadata().getPlace(); + if (place != null) { + builder.add(place); + } + } + + ImmutableList<Place> places = builder.build(); + L.v(LogTags.TEMPLATE, "Updating %d visible places", places.size()); + requireNonNull(templateContext.getAppHostService(LocationMediator.class)) + .setCurrentPlaces(places); + } + + @Nullable + private RowWrapper getRowWrapperIfValid(int index) { + // The user may click on a row that is transitioning out and the index here may be invalid + // for the new rows being transitioned in. Ignore those cases. + // Theoretically this means that we may trigger a click on a new row that was + // not clicked on (e.g. if the user double-taps really fast on the previously row), but that + // seems like a low-probability scenario in real HU so we are not doing extra checks here. + List<RowWrapper> rowWrappers = mRowList.getRowWrappers(); + if (index >= rowWrappers.size()) { + return null; + } + + return rowWrappers.get(index); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowVisibilityObserver.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowVisibilityObserver.java new file mode 100644 index 0000000..eda9af1 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/RowVisibilityObserver.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static java.util.Objects.requireNonNull; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.LayoutManager; +import android.view.View; +import android.view.View.OnLayoutChangeListener; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.android.car.libraries.apphost.logging.L; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.ui.recyclerview.CarUiRecyclerView; +import com.android.car.ui.recyclerview.CarUiRecyclerView.OnScrollListener; + +/** + * Observes the visibility change of the given {@link RecyclerView}. + * + * <p>Since we do not own the layout manager of {@link RecyclerView}, this class can be used to + * listen for the visibility changes of the recycler view due to scrolling. + */ +public class RowVisibilityObserver { + + /** Listener for the item visibility changes. */ + interface OnItemVisibilityChangedListener { + + /** Callback when item visibility changes. */ + void sendItemVisibilityChanged(int startIndexInclusive, int endIndexExclusive); + } + + private static final int MSG_HANDLE_VISIBLE_ROWS_CHANGE = 1; + private static final int HANDLE_ROW_CHANGE_DELAY_MILLIS = 150; + private static final int INVALID_ROW_INDEX = Integer.MIN_VALUE; + + @NonNull private final CarUiRecyclerView mRecyclerView; + private final Handler mHandler = new Handler(Looper.getMainLooper(), new HandlerCallback()); + private final OnScrollListener mOnScrollListener = + new CarUiRecyclerView.OnScrollListener() { + // Suppressing error for referencing handleVisibleRowsChange() before + // initialization of RowVisibilityObserver. + @SuppressWarnings("nullness:method.invocation") + @Override + public void onScrollStateChanged(CarUiRecyclerView recyclerView, int newState) { + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + handleVisibleRowsChange(); + } + } + + @Override + public void onScrolled(CarUiRecyclerView recyclerView, int dx, int dy) {} + }; + + private final OnLayoutChangeListener mOnLayoutChangeListener = + new OnLayoutChangeListener() { + // Suppressing error for referencing handleVisibleRowsChange() before + // initialization of RowVisibilityObserver and mReceyclerView being null. + @SuppressWarnings("nullness") + @Override + public void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + if (mRecyclerView.getScrollState() == RecyclerView.SCROLL_STATE_IDLE) { + handleVisibleRowsChange(); + } + } + }; + + @Nullable private OnItemVisibilityChangedListener mListener; + private int mFirstVisibleRowIndex; + private int mLastVisibleRowIndexExclusive; + + /** Returns an instance of {@link RowVisibilityObserver}. */ + public static RowVisibilityObserver create(@NonNull CarUiRecyclerView recyclerView) { + return new RowVisibilityObserver(requireNonNull(recyclerView)); + } + + /** Sets an {@link OnItemVisibilityChangedListener}. */ + public void setOnItemVisibilityChangedListener( + @NonNull OnItemVisibilityChangedListener listener) { + requireNonNull(listener); + + // Remove any existing listener. + removeOnItemVisibilityChangedListener(); + + mListener = listener; + + // Reset the cached start/end indices, so that the newly-set listener will be invoked even + // if the visible rows might not have changed. + mFirstVisibleRowIndex = INVALID_ROW_INDEX; + mLastVisibleRowIndexExclusive = INVALID_ROW_INDEX; + + mRecyclerView.addOnScrollListener(mOnScrollListener); + mRecyclerView.addOnLayoutChangeListener(mOnLayoutChangeListener); + } + + /** Removes any existing {@link OnItemVisibilityChangedListener}. */ + public void removeOnItemVisibilityChangedListener() { + if (mListener == null) { + return; + } + + mRecyclerView.removeOnScrollListener(mOnScrollListener); + mRecyclerView.removeOnLayoutChangeListener(mOnLayoutChangeListener); + mListener = null; + } + + /** Creates a {@link RowVisibilityObserver} for given {@link RecyclerView}. */ + private RowVisibilityObserver(@NonNull CarUiRecyclerView recyclerView) { + mRecyclerView = requireNonNull(recyclerView); + + // Start with an invalid index, so that the newly-set listener will be invoked. + mFirstVisibleRowIndex = INVALID_ROW_INDEX; + mLastVisibleRowIndexExclusive = INVALID_ROW_INDEX; + } + + + /** Sends a message to the handler to publish item visibility change event. */ + private void handleVisibleRowsChange() { + mHandler.removeMessages(MSG_HANDLE_VISIBLE_ROWS_CHANGE); + Message message = mHandler.obtainMessage(MSG_HANDLE_VISIBLE_ROWS_CHANGE); + if (mRecyclerView.getRecyclerViewChildCount() == 0) { + // When a full data refresh happens in the adapter that backs the recycler view, the + // view reports no visible items first for a few milliseconds, and then reports the new + // updated items. + // This ephemeral state of emptiness can cause flickering for the views that listen to + // the published events (e.g. the map view which clears and renders pins in the map). + // This is a work around by adding a short delay before sending the item visibility + // change event. + // TODO(b/183989613): Possibly remove once list diffing is implemented. + mHandler.sendMessageDelayed(message, HANDLE_ROW_CHANGE_DELAY_MILLIS); + } else { + mHandler.sendMessage(message); + } + } + + /** A {@link Handler.Callback} used to process the message queue for the visibility events. */ + private class HandlerCallback implements Handler.Callback { + + /** Publishes the item visibility changed event to the listener. */ + @Override + public boolean handleMessage(Message msg) { + if (msg.what == MSG_HANDLE_VISIBLE_ROWS_CHANGE) { + int firstVisibleRowIndex = mRecyclerView.findFirstCompletelyVisibleItemPosition(); + int lastVisibleRowIndex = mRecyclerView.findLastCompletelyVisibleItemPosition(); + int lastVisibleRowIndexExclusive = lastVisibleRowIndex + 1; + + L.d( + LogTags.TEMPLATE, + "Handling visible rows in range (%d, %d)", + firstVisibleRowIndex, + lastVisibleRowIndexExclusive); + + if (firstVisibleRowIndex == mFirstVisibleRowIndex + && lastVisibleRowIndexExclusive == mLastVisibleRowIndexExclusive) { + return true; + } + + if (mListener != null) { + mListener.sendItemVisibilityChanged(firstVisibleRowIndex, lastVisibleRowIndexExclusive); + } + + mFirstVisibleRowIndex = firstVisibleRowIndex; + mLastVisibleRowIndexExclusive = lastVisibleRowIndexExclusive; + + return true; + } else { + L.w(LogTags.TEMPLATE, "Unknown message: %s", msg); + } + return false; + } + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/SearchHeaderView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/SearchHeaderView.java new file mode 100644 index 0000000..4154695 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/SearchHeaderView.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import static com.google.common.base.Strings.nullToEmpty; + +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.EditText; +import androidx.annotation.Nullable; +import androidx.car.app.model.Action; +import androidx.car.app.model.ActionStrip; +import androidx.car.app.model.SearchCallbackDelegate; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.distraction.constraints.ActionsConstraints; +import com.android.car.libraries.apphost.input.InputManager; +import com.android.car.ui.core.CarUi; +import com.android.car.ui.toolbar.SearchMode; +import com.android.car.ui.toolbar.ToolbarController; + +/** A view that displays the header for the search templates. */ +public class SearchHeaderView extends AbstractHeaderView { + private final CarEditTextWrapper mEditableSearchBar; + private final EditText mSearchBar; + + private SearchHeaderView( + TemplateContext templateContext, + ToolbarController toolbarController, + View rootView, + @Nullable String initialSearchText, + @Nullable SearchCallbackDelegate searchCallbackDelegate, + boolean keyboardOpened) { + super(templateContext, toolbarController); + + InputManager mInputManager = templateContext.getInputManager(); + mToolbarController.setSearchMode(SearchMode.SEARCH); + mSearchBar = rootView.requireViewById(com.android.car.ui.R.id.car_ui_toolbar_search_bar); + mEditableSearchBar = new CarEditTextWrapper(mSearchBar, mInputManager); + + toolbarController.setSearchQuery(nullToEmpty(initialSearchText)); + + if (searchCallbackDelegate != null) { + mToolbarController.registerOnSearchListener( + query -> + templateContext + .getAppDispatcher() + .dispatchSearchTextChanged(searchCallbackDelegate, query)); + + toolbarController.registerOnSearchCompletedListener( + () -> { + String query = mSearchBar.getText().toString(); + templateContext + .getAppDispatcher() + .dispatchSearchSubmitted(searchCallbackDelegate, query); + }); + } + + if (keyboardOpened) { + mInputManager.startInput(mEditableSearchBar); + } + + // TODO(b/179220417): Handle disabling search while driving + } + + /** Returns the searchBar of the header */ + public EditText getSearchBar() { + return mSearchBar; + } + + /** Returns the {@link InputConnection} for the search bar. */ + public InputConnection onCreateInputConnection(EditorInfo editorInfo) { + return mEditableSearchBar.onCreateInputConnection(editorInfo); + } + + /** Updates the optional button in the header. */ + @Override + public void setAction(@Nullable Action action) { + super.setAction(action); + } + + /** Updates the {@link ActionStrip} associated with this toolbar */ + @Override + public void setActionStrip(@Nullable ActionStrip actionStrip, ActionsConstraints constraints) { + super.setActionStrip(actionStrip, constraints); + boolean hasMenuItems = actionStrip != null && !actionStrip.getActions().isEmpty(); + mToolbarController.setShowMenuItemsWhileSearching(hasMenuItems); + } + + /** Updates the search hint. */ + public void setHint(@Nullable String searchHint) { + mToolbarController.setSearchHint(searchHint != null ? searchHint : ""); + } + + /** Installs a {@link HeaderView} around the given container view */ + @SuppressWarnings("nullness:argument") // InsetsChangedListener is nullable. + public static SearchHeaderView install( + TemplateContext templateContext, + View container, + View rootView, + @Nullable String initialSearchText, + @Nullable SearchCallbackDelegate searchCallbackDelegate, + boolean keyboardOpened) { + ToolbarController toolbarController = CarUi.installBaseLayoutAround(container, null, true); + if (toolbarController == null) { + throw new NullPointerException("Toolbar Controller could not be created."); + } + return new SearchHeaderView( + templateContext, + toolbarController, + rootView, + initialSearchText, + searchCallbackDelegate, + keyboardOpened); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/StringReplacementSpan.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/StringReplacementSpan.java new file mode 100644 index 0000000..b93aea3 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/StringReplacementSpan.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Paint.FontMetricsInt; +import android.graphics.Rect; +import android.text.style.ReplacementSpan; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +/** + * A simple class for replacement the text that this span is attached to with the given replacement. + */ +public class StringReplacementSpan extends ReplacementSpan { + + private final String mReplacementText; + + public StringReplacementSpan(String text) { + mReplacementText = text; + } + + /** Returns the replacement string for replacing the attached text. */ + @VisibleForTesting + public String getReplacementText() { + return mReplacementText; + } + + @Override + public int getSize( + Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) { + Rect bounds = new Rect(); + paint.getTextBounds(mReplacementText, 0, mReplacementText.length(), bounds); + return bounds.width(); + } + + @Override + public void draw( + Canvas canvas, + CharSequence text, + int start, + int end, + float x, + int top, + int y, + int bottom, + Paint paint) { + canvas.drawText(mReplacementText, x, y, paint); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ViewUtils.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ViewUtils.java new file mode 100644 index 0000000..a9a98d4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/ViewUtils.java @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.libraries.templates.host.view.widgets.common; + +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.graphics.Rect; +import android.view.MotionEvent; +import android.view.TouchDelegate; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import androidx.annotation.Nullable; +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.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + +/** Utility class for view operations. */ +public class ViewUtils { + private ViewUtils() {} + + /** A {@link TouchDelegate} that allows combining multiple {@link TouchDelegate}s into one */ + private static class TouchDelegateComposite extends TouchDelegate { + private static class TouchDelegateInfo { + final TouchDelegate mTouchDelegate; + @Nullable final WeakReference<View> mTargetView; + + TouchDelegateInfo(TouchDelegate touchDelegate, @Nullable View targetView) { + mTouchDelegate = touchDelegate; + mTargetView = targetView != null ? new WeakReference<>(targetView) : null; + } + } + + private final List<TouchDelegateInfo> delegates = new ArrayList<>(); + + private static final Rect emptyRect = new Rect(); + + public TouchDelegateComposite(View view) { + super(emptyRect, view); + } + + public void addDelegate(TouchDelegate delegate, @Nullable View targetView) { + if (delegate != null) { + delegates.add(new TouchDelegateInfo(delegate, targetView)); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + boolean res = false; + float x = event.getX(); + float y = event.getY(); + for (TouchDelegateInfo delegateInfo : delegates) { + event.setLocation(x, y); + if (delegateInfo.mTargetView != null && delegateInfo.mTargetView.get() == null) { + throw new IllegalStateException("Invalid touch delegation, target view has be removed"); + } + res = delegateInfo.mTouchDelegate.onTouchEvent(event) || res; + } + return res; + } + } + + // Returns true if {@code child} is a descendant of {@code parent}. + private static boolean isDescendant(View parent, View child) { + View current = child; + while (current != null) { + if (current == parent) { + return true; + } + if (!(current.getParent() instanceof View)) { + return false; + } + current = (View) current.getParent(); + } + return false; + } + + /** + * Sets the tap target for the given view to encompass at least the area of a square of the given + * dimensions. + * + * <p>If the current tap area is already larger in either dimension, method will not shrink it + * (hence "min" tap target). + * + * <p>If the current tap area is smaller, method will expand it equally on either side to meet the + * minimum size. + * + * <p><b>Important: This method works by adding a {@link TouchDelegate} to the container view.</b> + * The caller must make sure this method is invoked only once per view. Otherwise, multiple {@link + * TouchDelegate} instances will be added to the container, which could cause duplicate click + * events. + * + * @param containerView the view where a {@link TouchDelegate} will be added. + * @param view the view to potentially expand the tap target for. + * @param tapTargetSize the dimensions of a square that will become the new minimum tap target for + * the given view. + */ + public static void setMinTapTarget(ViewGroup containerView, View view, int tapTargetSize) { + containerView.post( + () -> { + // Return if the view has already been removed from the view hierarchy or has unexpected + // parent. + if (!(view.getParent() instanceof View) + || !isDescendant(containerView, (View) view.getParent())) { + L.d(LogTags.TEMPLATE, "Cannot set min tap target for view %s", view); + return; + } + + Rect rect = new Rect(); + view.getHitRect(rect); + containerView.offsetDescendantRectToMyCoords((View) view.getParent(), rect); + + int rectHeight = rect.height(); + if (rectHeight < tapTargetSize) { + int delta = (tapTargetSize - rectHeight) / 2; + rect.top -= delta; + rect.bottom += delta; + } + + int rectWidth = rect.width(); + if (rectWidth < tapTargetSize) { + int delta = (tapTargetSize - rectWidth) / 2; + rect.left -= delta; + rect.right += delta; + } + + TouchDelegate parentTouchDelegate = containerView.getTouchDelegate(); + TouchDelegate newDelegate = new TouchDelegate(rect, view); + if (parentTouchDelegate != null) { + if (parentTouchDelegate instanceof TouchDelegateComposite) { + ((TouchDelegateComposite) parentTouchDelegate).addDelegate(newDelegate, view); + newDelegate = parentTouchDelegate; + } else { + TouchDelegateComposite composite = new TouchDelegateComposite(view); + composite.addDelegate(parentTouchDelegate, null); + composite.addDelegate(newDelegate, view); + newDelegate = composite; + } + } + containerView.setTouchDelegate(newDelegate); + }); + } + + /** + * Enforce the minimum and maximum size limit to the given view. + * + * <p>The view width and height sizes must be equal. + */ + public static void enforceViewSizeLimit(View view, int minSize, int maxSize) { + enforceViewSizeLimit( + view, + /* minWidth= */ minSize, + /* maxWidth= */ maxSize, + /* minHeight= */ minSize, + /* maxHeight= */ maxSize); + } + + /** Enforce the minimum and maximum width and height limits to the given view. */ + public static void enforceViewSizeLimit( + View view, int minWidth, int maxWidth, int minHeight, int maxHeight) { + LayoutParams layoutParams = view.getLayoutParams(); + if (layoutParams == null) { + return; + } + + int width = getValueInRange(layoutParams.width, minWidth, maxWidth); + int height = getValueInRange(layoutParams.height, minHeight, maxHeight); + layoutParams.width = width; + layoutParams.height = height; + view.setLayoutParams(layoutParams); + } + + /** Logs a telemetry event with the given {@link UiAction} and {@link TemplateContext} */ + public static void logCarAppTelemetry(TemplateContext templateContext, UiAction action) { + logCarAppTelemetry( + templateContext, + TelemetryEvent.newBuilder(action) + .setComponentName(templateContext.getCarAppPackageInfo().getComponentName())); + } + + /** + * Logs a telemetry event with the given {@link UiAction}, action count and {@link + * TemplateContext} + */ + public static void logCarAppTelemetry( + TemplateContext templateContext, UiAction action, int actionCount) { + logCarAppTelemetry( + templateContext, + TelemetryEvent.newBuilder(action) + .setComponentName(templateContext.getCarAppPackageInfo().getComponentName()) + .setItemsLoadedCount(actionCount)); + } + + /** + * Logs a telemetry event with the given {@link TelemetryEvent.Builder} and {@link + * TemplateContext} + */ + public static void logCarAppTelemetry( + TemplateContext templateContext, TelemetryEvent.Builder builder) { + TelemetryHandler telemetry = templateContext.getTelemetryHandler(); + telemetry.logCarAppTelemetry(builder); + } + + /** + * Returns the capped value between the min and max range. + * + * <p>If the given value is less than or equal to 0 (e.g. MATCH_CONSTRAINT (0), MATCH_PARENT (-1), + * or WRAP_CONTENT (-2)), the original value will be returned. + */ + private static int getValueInRange(int value, int min, int max) { + if (value <= 0) { + return value; + } + + int newValue = value; + newValue = min(newValue, max); + newValue = max(newValue, min); + return newValue; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/anim/fab_view_animation_fade_in.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/anim/fab_view_animation_fade_in.xml new file mode 100644 index 0000000..5a8ca7b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/anim/fab_view_animation_fade_in.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:ordering="together"> + <objectAnimator + android:duration="@integer/action_strip_animation_duration_millis" + android:propertyName="scaleX" + android:valueType="floatType" + android:valueFrom=".7f" + android:valueTo="1f"/> + <objectAnimator + android:duration="@integer/action_strip_animation_duration_millis" + android:propertyName="scaleY" + android:valueType="floatType" + android:valueFrom=".7f" + android:valueTo="1f"/> + <objectAnimator + android:duration="@integer/action_strip_animation_duration_millis" + android:propertyName="alpha" + android:valueType="floatType" + android:valueFrom="0f" + android:valueTo="1f"/> +</set> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/anim/fab_view_animation_fade_out.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/anim/fab_view_animation_fade_out.xml new file mode 100644 index 0000000..33f5c80 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/anim/fab_view_animation_fade_out.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<set xmlns:android="http://schemas.android.com/apk/res/android" + android:ordering="together"> + <objectAnimator + android:duration="@integer/action_strip_animation_duration_millis" + android:propertyName="scaleX" + android:valueType="floatType" + android:valueFrom="1f" + android:valueTo=".7f"/> + <objectAnimator + android:duration="@integer/action_strip_animation_duration_millis" + android:propertyName="scaleY" + android:valueType="floatType" + android:valueFrom="1f" + android:valueTo=".7f"/> + <objectAnimator + android:duration="@integer/action_strip_animation_duration_millis" + android:propertyName="alpha" + android:valueType="floatType" + android:valueFrom="1f" + android:valueTo="0f"/> +</set> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker.xml new file mode 100644 index 0000000..debd0a3 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="36dp" + android:height="50dp" + android:viewportWidth="36" + android:viewportHeight="50"> + <path + android:pathData="M28.2,32c5.2,-4.9 6.8,-8.3 6.8,-14c0,-9.4 -7.6,-17 -17,-17S1,8.6 1,18c0,5.6 1.6,9 6.7,14l1.2,1c5,4.9 6.7,8.2 7.3,14.1c0.1,1.1 0.8,1.8 1.8,1.8c1,0 1.7,-0.7 1.8,-1.8c0.5,-5.8 2.2,-9.1 7.2,-14L28.2,32z" + android:strokeWidth="0" + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:strokeColor="#00000000"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker_border.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker_border.xml new file mode 100644 index 0000000..063b6eb --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker_border.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="36dp" + android:height="50dp" + android:viewportWidth="36" + android:viewportHeight="50"> + <path + android:pathData="M18,49.9C16.5,49.9 15.3,48.8 15.2,47.2C14.7,41.6 13.1,38.5 8.3,33.8L7,32.7C1.7,27.5 0,23.9 0,18C0,8.1 8.1,0 18,0C27.9,0 36,8.1 36,18C36,24 34.3,27.6 28.9,32.7L28.6,33L27.7,33.8C22.9,38.5 21.3,41.6 20.8,47.2C20.6,48.8 19.5,49.9 18,49.9ZM8.3,31.2L8.7,31.5L9.7,32.4C14.9,37.5 16.7,41 17.2,47.1C17.2,47.6 17.5,48 18,48C18.6,48 18.8,47.4 18.8,47.1C19.3,41.1 21.2,37.5 26.3,32.5L27.5,31.4C32.5,26.6 34,23.6 34,18.1C34,9.3 26.8,2.1 18,2.1C9.2,2.1 2,9.2 2,18C2,23.3 3.5,26.4 8.3,31.2Z" + android:strokeWidth="1" + android:fillColor="#80868B" + android:fillType="nonZero" + android:strokeColor="#00000000"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker_circle.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker_circle.xml new file mode 100644 index 0000000..9953ce3 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/anchor_marker_circle.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="36dp" + android:height="50dp" + android:viewportWidth="36" + android:viewportHeight="50"> + <path + android:pathData="M18,18m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0" + android:strokeWidth="1" + android:fillColor="#80868B" + android:fillType="nonZero" + android:strokeColor="#00000000"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/empty.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/empty.xml new file mode 100644 index 0000000..89d860b --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/empty.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- This is a blank shape, 0x0 in size, that works around the fact that the + android:textSelectHandle xml property requires a drawable with a defined size. --> +<shape + xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle" > + <size + android:width="0dp" + android:height="0dp" /> +</shape> + diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/ic_error.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/ic_error.xml new file mode 100644 index 0000000..9393c31 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/ic_error.xml @@ -0,0 +1,19 @@ +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector android:height="64dp" android:viewportHeight="24" + android:viewportWidth="24" android:width="64dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="#FFFFFF" android:pathData="M12,5.99L19.53,19L4.47,19L12,5.99M12,2L1,21h22L12,2zM13,16h-2v2h2v-2zM13,10h-2v4h2v-4z"/> +</vector> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/ic_pan_overlay.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/ic_pan_overlay.xml new file mode 100644 index 0000000..79933ad --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/drawable/ic_pan_overlay.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="290dp" + android:height="258dp" + android:viewportWidth="290" + android:viewportHeight="258"> + <path + android:pathData="M244.268,130.768C245.244,129.791 245.244,128.209 244.268,127.232L228.358,111.322C227.382,110.346 225.799,110.346 224.822,111.322C223.846,112.299 223.846,113.881 224.822,114.858L238.964,129L224.822,143.142C223.846,144.118 223.846,145.701 224.822,146.678C225.799,147.654 227.381,147.654 228.358,146.678L244.268,130.768ZM241,131.5L242.5,131.5L242.5,126.5L241,126.5L241,131.5Z" + android:fillColor="#000000"/> + <path + android:pathData="M54.232,127.232C53.256,128.208 53.256,129.791 54.232,130.768L70.142,146.678C71.118,147.654 72.701,147.654 73.677,146.678C74.654,145.702 74.654,144.119 73.677,143.143L59.535,129L73.678,114.858C74.654,113.882 74.655,112.299 73.678,111.323C72.702,110.347 71.119,110.346 70.143,111.323L54.232,127.232ZM57.5,126.5L56,126.5L56,131.5L57.5,131.5L57.5,126.5Z" + android:fillColor="#000000"/> + <path + android:pathData="M147.232,224.268C148.209,225.244 149.791,225.244 150.768,224.268L166.678,208.358C167.654,207.381 167.654,205.799 166.678,204.822C165.701,203.846 164.118,203.846 163.142,204.822L149,218.964L134.858,204.822C133.881,203.846 132.299,203.846 131.322,204.822C130.346,205.799 130.346,207.382 131.322,208.358L147.232,224.268ZM146.5,221L146.5,222.5L151.5,222.5L151.5,221L146.5,221Z" + android:fillColor="#000000"/> + <path + android:pathData="M150.768,34.232C149.791,33.256 148.209,33.256 147.232,34.232L131.322,50.142C130.346,51.118 130.346,52.701 131.322,53.678C132.299,54.654 133.882,54.654 134.858,53.678L149,39.535L163.142,53.678C164.118,54.654 165.701,54.654 166.678,53.678C167.654,52.701 167.654,51.118 166.678,50.142L150.768,34.232ZM151.5,39L151.5,36L146.5,36L146.5,39L151.5,39Z" + android:fillColor="#000000"/> +</vector> + diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_list_row.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_list_row.xml new file mode 100644 index 0000000..171f33a --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_list_row.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView + android:id="@+id/action_button_list_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="?templatePlainContentHorizontalPadding" + android:layout_marginVertical="?templateActionButtonListRowVerticalSpacing" + android:orientation="horizontal" + android:gravity="center" /> +</FrameLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_list_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_list_view.xml new file mode 100644 index 0000000..d9fdc29 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_list_view.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:orientation="horizontal" + android:gravity="center" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="?templatePlainContentHorizontalPadding"> +</com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view.xml new file mode 100644 index 0000000..1a8fd6d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.ActionButtonView + xmlns:android="http://schemas.android.com/apk/res/android" + android:clickable="true" + android:focusable="true" + android:layout_width="wrap_content" + android:layout_height="?templateActionButtonHeight" + style="?templateActionButtonStyle"/> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_icon.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_icon.xml new file mode 100644 index 0000000..c178a8f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_icon.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.CarImageView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/action_icon" + android:layout_width="?templateActionIconSize" + android:layout_height="?templateActionIconSize" + app:imageMinWidth="?templateActionIconSizeMin" + app:imageMaxWidth="?templateActionIconSizeMax" + app:imageMinHeight="?templateActionIconSizeMin" + app:imageMaxHeight="?templateActionIconSizeMax" + android:layout_gravity="center" + android:scaleType="fitCenter" + tools:ignore="ContentDescription"/> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_icon_text.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_icon_text.xml new file mode 100644 index 0000000..7d4bdce --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_icon_text.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_gravity="center" + android:orientation="horizontal" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingStart="?templateActionIconTextStartSpacing" + android:paddingEnd="?templateActionIconTextEndSpacing"> + + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/action_icon" + android:layout_width="?templateActionIconSize" + android:layout_height="?templateActionIconSize" + app:imageMinWidth="?templateActionIconSizeMin" + app:imageMaxWidth="?templateActionIconSizeMax" + app:imageMinHeight="?templateActionIconSizeMin" + app:imageMaxHeight="?templateActionIconSizeMax" + android:layout_gravity="center" + android:scaleType="fitCenter" + tools:ignore="ContentDescription"/> + + <CarUiTextView + android:id="@+id/action_text" + style="?templateActionButtonTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginStart="?templateActionIconToTextSpacing" + android:maxEms="?templateActionButtonTextMaxEmsWithIcon"/> +</LinearLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_text.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_text.xml new file mode 100644 index 0000000..c5b82d4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_button_view_text.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<CarUiTextView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/action_text" + style="?templateActionButtonTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="?templateActionTextHorizontalSpacing" + android:layout_gravity="center" + android:maxEms="?templateActionButtonTextMaxEmsNoIcon"/> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_strip_view_floating.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_strip_view_floating.xml new file mode 100644 index 0000000..1a75313 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/action_strip_view_floating.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- +An action strip with floating buttons that anchors to the top right of the +screen, meant to be used in conjunction with half-screen, card-style templates. + +IMPORTANT: parents of this view should have clipChildren set to false so that +the shadows don't get clipped. +--> +<com.android.car.libraries.templates.host.view.widgets.common.ActionStripView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:focusable="false" + android:visibility="gone" + android:clipChildren="false" + app:fabAppearance="?templateActionStripFabAppearance"> + + <LinearLayout + android:id="@+id/action_strip_touch_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="?templateActionStripPadding" + android:orientation="vertical"> + <LinearLayout + android:id="@+id/action_strip_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clipChildren="false" + android:layout_gravity="center_vertical|end" + android:orientation="horizontal" /> + + <LinearLayout + android:id="@+id/action_strip_container_secondary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clipChildren="false" + android:layout_gravity="center_vertical|end" + android:orientation="horizontal" /> + </LinearLayout> + +</com.android.car.libraries.templates.host.view.widgets.common.ActionStripView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/card_container.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/card_container.xml new file mode 100644 index 0000000..848f4ba --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/card_container.xml @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- The card has a minimum and maximum heights, the latter never going past + the screen height. --> +<com.android.car.libraries.templates.host.view.widgets.common.BleedingCardView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + style="?templateCardContentContainerStyle" + android:visibility="gone" + android:focusable="false" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="0.0" + app:layout_constraintHeight_default="wrap" + app:layout_constraintHeight_min="?templateCardContentContainerMinHeight" + android:layout_marginStart="?templateCardContentContainerStartMargin" + android:layout_marginTop="?templateCardContentContainerTopMargin" + android:layout_width="?templateCardContentContainerDefaultWidth" + android:layout_height="0dp" + tools:ignore="MissingClass"> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + <include layout="@layout/card_header_layout"/> + <View + android:layout_width="match_parent" + android:layout_height="?templateDividerThickness" + android:background="?templateDividerColor"/> + <include + layout="@layout/content_view" + android:id="@+id/content_view"/> + </LinearLayout> +</com.android.car.libraries.templates.host.view.widgets.common.BleedingCardView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/card_header_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/card_header_layout.xml new file mode 100644 index 0000000..6540a2c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/card_header_layout.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:focusable="false" + tools:ignore="MergeRootFrame"> + <com.android.car.libraries.templates.host.view.widgets.common.CardHeaderView + android:id="@+id/header_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:focusable="false" + android:descendantFocusability="afterDescendants"/> +</FrameLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/clickable_span_text_container.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/clickable_span_text_container.xml new file mode 100644 index 0000000..2aad758 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/clickable_span_text_container.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<com.android.car.libraries.templates.host.view.widgets.common.ClickableSpanTextContainer + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:focusable="false" + android:descendantFocusability="afterDescendants" > + + <CarUiTextView + android:id="@+id/clickable_span_text_view" + style="?templateSignInAdditionalTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:focusable="true" /> + +</com.android.car.libraries.templates.host.view.widgets.common.ClickableSpanTextContainer> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/content_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/content_view.xml new file mode 100644 index 0000000..2399b86 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/content_view.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.ContentView + xmlns:android="http://schemas.android.com/apk/res/android" + android:orientation="vertical" + android:layout_height="match_parent" + android:layout_width="match_parent"> + + <!-- A container for the content views's content. --> + <com.android.car.ui.FocusArea + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_gravity="center" + android:gravity="center" + android:orientation="vertical" + android:clipChildren="true" + android:layout_weight="1"/> + +</com.android.car.libraries.templates.host.view.widgets.common.ContentView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/driving_message_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/driving_message_view.xml new file mode 100644 index 0000000..090ebf5 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/driving_message_view.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/driving_message_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:gravity="center" + android:orientation="vertical" + android:background="@color/template_black" + android:visibility="gone"> + <!-- An icon shown on top of the contents. --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/driving_message_icon" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinWidth="?templateLargeImageSizeMin" + app:imageMaxWidth="?templateLargeImageSizeMax" + app:imageMinHeight="?templateLargeImageSizeMin" + app:imageMaxHeight="?templateLargeImageSizeMax" + android:src="@drawable/ic_error" + tools:ignore="ContentDescription" /> + + <!-- The title displayed below the icon. --> + <CarUiTextView + android:id="@+id/driving_message_text" + style="?templateMessageTitleTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginTop="?templateMessageTitleTopSpacing" + android:foreground="@drawable/no_content_view_focus_ring" /> +</LinearLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_icon_text.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_icon_text.xml new file mode 100644 index 0000000..c638c3f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_icon_text.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_gravity="center" + android:orientation="horizontal" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="?templateActionIconTextStartSpacing" + android:layout_marginEnd="?templateActionIconTextEndSpacing"> + + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/action_icon" + android:layout_width="?templateActionIconSize" + android:layout_height="?templateActionIconSize" + app:imageMinWidth="?templateActionIconSizeMin" + app:imageMaxWidth="?templateActionIconSizeMax" + app:imageMinHeight="?templateActionIconSizeMin" + app:imageMaxHeight="?templateActionIconSizeMax" + android:layout_gravity="center" + android:scaleType="fitCenter" + tools:ignore="ContentDescription"/> + + <CarUiTextView + android:id="@+id/action_text" + style="?templateActionButtonTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginStart="?templateActionIconToTextSpacing" + android:maxEms="?templateFabTextMaxEmsWithIcon"/> +</LinearLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_text.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_text.xml new file mode 100644 index 0000000..3bc77c8 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_text.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<CarUiTextView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/action_text" + style="?templateActionButtonTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="?templateActionTextHorizontalSpacing" + android:layout_gravity="center" + android:maxEms="?templateFabTextMaxEmsNoIcon"/> + diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_text_no_icon.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_text_no_icon.xml new file mode 100644 index 0000000..826c98f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/fab_view_text_no_icon.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<CarUiTextView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/action_text" + style="?templateActionButtonTextStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:paddingStart="@dimen/template_padding_5" + android:paddingEnd="@dimen/template_padding_5" + android:maxEms="?templateFabTextMaxEmsNoIcon"/> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_no_divider_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_no_divider_view.xml new file mode 100644 index 0000000..6855f5c --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_no_divider_view.xml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.RowListView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:markerAppearance="?templateListMarkerAppearance"> + + <FrameLayout + android:id="@+id/progress_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:minHeight="?templateRowMinHeight" + android:focusable="true" + android:visibility="gone"> + <com.android.car.libraries.templates.host.view.widgets.common.CarProgressBar + android:id="@+id/progress" + style="?templateLoadingSpinnerStyle" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinSize="?templateLargeImageSizeMin" + app:imageMaxSize="?templateLargeImageSizeMax" + android:layout_gravity="center" /> + </FrameLayout> + + <!-- A text view displaying a message when the list is empty. --> + <CarUiTextView + style="?templateRowListEmptyTextStyle" + android:id="@+id/list_no_items_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:foreground="@drawable/no_content_view_focus_ring" /> + + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/list_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clickable="true" + android:clipToPadding="true" + app:carUiSize="large" + app:layoutStyle="linear" + app:enableDivider="false" /> + + <FrameLayout + android:id="@+id/large_image_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="top|end" + android:clickable="false" + android:focusable="false" + android:visibility="gone"> + <!-- + We programmatically calculate the width and height of the wrapping + large_image_container, and allow the ImageView to stretch to fill the + container width if needed while maintaining the source's aspect ratio. + --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/large_image" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="top|center" + android:clickable="false" + android:focusable="false" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + tools:ignore="ContentDescription" /> + </FrameLayout> + +</com.android.car.libraries.templates.host.view.widgets.common.RowListView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_row_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_row_view.xml new file mode 100644 index 0000000..581b88d --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_row_view.xml @@ -0,0 +1,202 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + xmlns:tools="http://schemas.android.com/tools" + android:tag="carUiListItem"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="0dp" + app:layout_constraintEnd_toStartOf="@id/large_image_spacer" + app:layout_constraintStart_toStartOf="parent" + android:layout_height="wrap_content" + android:minHeight="@dimen/car_ui_list_item_height"> + + <!-- The following touch interceptor views are sized to encompass the specific sub-sections of + the list item view to easily control the bounds of a background ripple effects. --> + <View + android:id="@+id/car_ui_list_item_touch_interceptor" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@drawable/car_ui_list_item_background" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <!-- This touch interceptor does not include the action container --> + <View + android:id="@+id/car_ui_list_item_reduced_touch_interceptor" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@drawable/car_ui_list_item_background" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/car_ui_list_item_action_container" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/car_ui_list_item_start_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="?templateFullRowStartPadding" /> + + <FrameLayout + android:id="@+id/car_ui_list_item_icon_container" + android:layout_width="@dimen/car_ui_list_item_icon_container_width" + android:layout_height="wrap_content" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="@+id/car_ui_list_item_start_guideline" + app:layout_constraintTop_toTopOf="parent"> + + <ImageView + android:id="@+id/car_ui_list_item_icon" + android:layout_width="@dimen/car_ui_list_item_icon_size" + android:layout_height="@dimen/car_ui_list_item_icon_size" + android:layout_gravity="center" + android:visibility="gone" + android:scaleType="fitCenter" + tools:ignore="ContentDescription"/> + + <ImageView + android:id="@+id/car_ui_list_item_content_icon" + android:layout_width="@dimen/car_ui_list_item_content_icon_width" + android:layout_height="@dimen/car_ui_list_item_content_icon_height" + android:layout_gravity="center" + android:visibility="gone" + android:scaleType="fitCenter" + tools:ignore="ContentDescription"/> + + <ImageView + android:id="@+id/car_ui_list_item_avatar_icon" + android:background="@drawable/car_ui_list_item_avatar_icon_outline" + android:layout_width="@dimen/car_ui_list_item_avatar_icon_width" + android:layout_height="@dimen/car_ui_list_item_avatar_icon_height" + android:layout_gravity="center" + android:visibility="gone" + android:scaleType="fitCenter" + tools:ignore="ContentDescription"/> + </FrameLayout> + + <CarUiTextView + android:id="@+id/car_ui_list_item_title" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/car_ui_list_item_text_start_margin" + style="?templateRowTitleStyle" + android:layout_marginTop="@dimen/car_ui_padding_2" + app:layout_constraintBottom_toTopOf="@+id/car_ui_list_item_body" + app:layout_constraintEnd_toStartOf="@+id/car_ui_list_item_action_container" + app:layout_constraintStart_toEndOf="@+id/car_ui_list_item_icon_container" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_chainStyle="packed" + app:layout_goneMarginBottom="@dimen/car_ui_padding_2" + app:layout_goneMarginStart="0dp"/> + <CarUiTextView + android:id="@+id/car_ui_list_item_body" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/car_ui_list_item_text_start_margin" + style="?templateRowSecondaryTextStyle" + android:layout_marginBottom="@dimen/car_ui_padding_2" + android:textAlignment="viewStart" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/car_ui_list_item_action_container" + app:layout_constraintStart_toEndOf="@+id/car_ui_list_item_icon_container" + app:layout_constraintTop_toBottomOf="@+id/car_ui_list_item_title" + app:layout_goneMarginTop="@dimen/car_ui_padding_2" + app:layout_goneMarginStart="0dp"/> + + <!-- This touch interceptor is sized and positioned to encompass the action container --> + <View + android:id="@+id/car_ui_list_item_action_container_touch_interceptor" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@drawable/car_ui_list_item_background" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="@id/car_ui_list_item_action_container" + app:layout_constraintEnd_toEndOf="@id/car_ui_list_item_action_container" + app:layout_constraintStart_toStartOf="@id/car_ui_list_item_action_container" + app:layout_constraintTop_toTopOf="@id/car_ui_list_item_action_container" /> + + <FrameLayout + android:id="@+id/car_ui_list_item_action_container" + android:layout_width="wrap_content" + android:minWidth="@dimen/car_ui_list_item_icon_container_width" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@+id/car_ui_list_item_end_guideline" + app:layout_constraintTop_toTopOf="parent"> + + <Switch + android:id="@+id/car_ui_list_item_switch_widget" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" /> + + <CheckBox + android:id="@+id/car_ui_list_item_checkbox_widget" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" /> + + <RadioButton + android:id="@+id/car_ui_list_item_radio_button_widget" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" /> + + <ImageView + android:id="@+id/car_ui_list_item_supplemental_icon" + android:layout_width="?templateFullRowChevronWidth" + android:layout_height="?templateFullRowChevronHeight" + android:layout_gravity="center" + android:scaleType="fitCenter" + tools:ignore="ContentDescription"/> + </FrameLayout> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/car_ui_list_item_end_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_end="?templateFullRowEndPadding" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + <Space + android:id="@+id/large_image_spacer" + android:layout_width="0dp" + android:layout_height="0dp" + app:layout_constraintWidth_percent="?templateRowListToLargeImageRatio" + app:layout_constraintWidth_default="percent" + app:layout_constraintWidth_max="?templateRowListLargeImageContainerMaxWidth" + app:layout_constraintEnd_toEndOf="parent" + android:visibility="gone" /> + +</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_view.xml new file mode 100644 index 0000000..2cadd94 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_list_view.xml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.RowListView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:markerAppearance="?templateListMarkerAppearance"> + + <FrameLayout + android:id="@+id/progress_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:minHeight="?templateRowMinHeight" + android:focusable="true" + android:visibility="gone"> + <com.android.car.libraries.templates.host.view.widgets.common.CarProgressBar + android:id="@+id/progress" + style="?templateLoadingSpinnerStyle" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinSize="?templateLargeImageSizeMin" + app:imageMaxSize="?templateLargeImageSizeMax" + android:layout_gravity="center" /> + </FrameLayout> + + <!-- A text view displaying a message when the list is empty. --> + <CarUiTextView + style="?templateRowListEmptyTextStyle" + android:id="@+id/list_no_items_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:foreground="@drawable/no_content_view_focus_ring" /> + + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/list_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clickable="true" + android:clipToPadding="true" + app:carUiSize="large" + app:layoutStyle="linear" + app:enableDivider="true" /> + + <FrameLayout + android:id="@+id/large_image_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="top|end" + android:clickable="false" + android:focusable="false" + android:visibility="gone"> + <!-- + We programmatically calculate the width and height of the wrapping + large_image_container, and allow the ImageView to stretch to fill the + container width if needed while maintaining the source's aspect ratio. + --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/large_image" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="top|center" + android:clickable="false" + android:focusable="false" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + tools:ignore="ContentDescription" /> + </FrameLayout> + +</com.android.car.libraries.templates.host.view.widgets.common.RowListView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_screen_header_layout.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_screen_header_layout.xml new file mode 100644 index 0000000..44a8ef3 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/full_screen_header_layout.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="?templateHeaderHeight" + android:paddingStart="@dimen/template_edge_column_margin" + android:paddingEnd="@dimen/template_edge_column_margin" + android:focusable="false" + tools:ignore="MergeRootFrame"> + <com.android.car.libraries.templates.host.view.widgets.common.CardHeaderView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/header_view" + android:layout_width="match_parent" + android:layout_height="?templateHeaderHeight" + android:focusable="false" + android:descendantFocusability="afterDescendants"/> +</FrameLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/grid_item_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/grid_item_view.xml new file mode 100644 index 0000000..7700102 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/grid_item_view.xml @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.GridItemView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingVertical="?templateGridItemVerticalSpacing" + android:paddingHorizontal="?templateGridItemHorizontalSpacing" + android:focusable="true" + android:gravity="center" + android:orientation="vertical"> + + <LinearLayout + android:id="@+id/grid_item_image_container" + android:layout_gravity="center_horizontal" + android:orientation="horizontal" + android:gravity="center" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + android:layout_marginBottom="?templateGridItemImageBottomPadding"> + <!-- The loading spinner. --> + <ProgressBar + android:id="@+id/grid_item_progress_bar" + style="?templateLoadingSpinnerStyle" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" /> + + <!-- The grid item image or icon. --> + <ImageView + android:id="@+id/grid_item_image" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:scaleType="fitCenter" + tools:ignore="ContentDescription" /> + </LinearLayout> + + <!-- A container with the title and a secondary text line. --> + <LinearLayout + android:id="@+id/grid_item_text_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:orientation="vertical"> + + <CarUiTextView + android:id="@+id/grid_item_title" + style="?templateGridItemTitleStyle" + android:layout_marginBottom="?templateGridItemTextBottomPadding" + android:visibility="gone" + android:maxWidth="?templateGridItemTextContainerMaxWidth" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal"/> + + <CarUiTextView + android:id="@+id/grid_item_text" + style="?templateGridItemTextStyle" + android:visibility="gone" + android:maxWidth="?templateGridItemTextContainerMaxWidth" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal"/> + </LinearLayout> +</com.android.car.libraries.templates.host.view.widgets.common.GridItemView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/grid_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/grid_view.xml new file mode 100644 index 0000000..f9ba1f5 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/grid_view.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.GridView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:clipChildren="true" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <FrameLayout + android:id="@+id/progress_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:focusable="true" + android:visibility="gone"> + <com.android.car.libraries.templates.host.view.widgets.common.CarProgressBar + android:id="@+id/progress" + style="?templateLoadingSpinnerStyle" + android:layout_width="?templateLargeImageSize" + android:layout_height="?templateLargeImageSize" + app:imageMinSize="?templateLargeImageSizeMin" + app:imageMaxSize="?templateLargeImageSizeMax" + android:layout_gravity="center" /> + </FrameLayout> + + <!-- A text view displaying a message when the list is empty. --> + <CarUiTextView + style="?templateGridEmptyTextStyle" + android:id="@+id/list_no_items_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:foreground="@drawable/no_content_view_focus_ring" /> + + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/grid_paged_list_view" + style="?templateGridStyle" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingHorizontal="?templatePlainContentHorizontalPadding" /> +</com.android.car.libraries.templates.host.view.widgets.common.GridView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/half_list_row_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/half_list_row_view.xml new file mode 100644 index 0000000..e15c9e6 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/half_list_row_view.xml @@ -0,0 +1,188 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="?templateHalfRowMinHeight" + android:tag="carUiListItem"> + + <!-- The following touch interceptor views are sized to encompass the specific sub-sections of + the list item view to easily control the bounds of a background ripple effects. --> + <View + android:id="@+id/car_ui_list_item_touch_interceptor" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@drawable/car_ui_list_item_background" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <!-- This touch interceptor does not include the action container --> + <View + android:id="@+id/car_ui_list_item_reduced_touch_interceptor" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@drawable/car_ui_list_item_background" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/car_ui_list_item_action_container" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/car_ui_list_item_start_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_begin="?templateHalfRowHorizontalPadding" /> + + <FrameLayout + android:id="@+id/car_ui_list_item_icon_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintStart_toStartOf="@+id/car_ui_list_item_start_guideline" + app:layout_constraintTop_toTopOf="@+id/car_ui_list_item_title" > + + <ImageView + android:id="@+id/car_ui_list_item_icon" + android:layout_width="?templateHalfRowImageSize" + android:layout_height="?templateHalfRowImageSize" + android:layout_gravity="center" + android:visibility="gone" + android:scaleType="fitCenter" /> + + <ImageView + android:id="@+id/car_ui_list_item_content_icon" + android:layout_width="?templateHalfRowImageSize" + android:layout_height="?templateHalfRowImageSize" + android:layout_gravity="center" + android:visibility="gone" + android:scaleType="fitCenter" /> + + <ImageView + android:id="@+id/car_ui_list_item_avatar_icon" + android:background="@drawable/car_ui_list_item_avatar_icon_outline" + android:layout_width="?templateHalfRowImageSize" + android:layout_height="?templateHalfRowImageSize" + android:layout_gravity="center" + android:visibility="gone" + android:scaleType="fitCenter" /> + </FrameLayout> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/car_ui_list_item_top_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + app:layout_constraintGuide_begin="?templateHalfRowVerticalPadding" /> + + <CarUiTextView + android:id="@+id/car_ui_list_item_title" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="?templateHalfRowImageToTextSpacing" + style="?templateRowTitleStyle" + app:layout_constraintBottom_toTopOf="@+id/car_ui_list_item_body" + app:layout_constraintEnd_toStartOf="@+id/car_ui_list_item_action_container" + app:layout_constraintStart_toEndOf="@+id/car_ui_list_item_icon_container" + app:layout_constraintTop_toTopOf="@+id/car_ui_list_item_top_guideline" + app:layout_constraintVertical_chainStyle="packed" + app:layout_goneMarginStart="@dimen/car_ui_padding_0" /> + <CarUiTextView + android:id="@+id/car_ui_list_item_body" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="?templateHalfRowImageToTextSpacing" + android:layout_marginTop="?templateHalfRowTextToTextSpacing" + style="?templateRowSecondaryTextStyle" + app:layout_constraintBottom_toBottomOf="@+id/car_ui_list_item_bottom_guideline" + app:layout_constraintEnd_toStartOf="@+id/car_ui_list_item_action_container" + app:layout_constraintStart_toEndOf="@+id/car_ui_list_item_icon_container" + app:layout_constraintTop_toBottomOf="@+id/car_ui_list_item_title" + app:layout_goneMarginStart="@dimen/car_ui_padding_0" /> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/car_ui_list_item_bottom_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" + app:layout_constraintGuide_end="?templateHalfRowVerticalPadding" /> + + <!-- This touch interceptor is sized and positioned to encompass the action container --> + <View + android:id="@+id/car_ui_list_item_action_container_touch_interceptor" + android:layout_width="0dp" + android:layout_height="0dp" + android:background="@drawable/car_ui_list_item_background" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="@id/car_ui_list_item_action_container" + app:layout_constraintEnd_toEndOf="@id/car_ui_list_item_action_container" + app:layout_constraintStart_toStartOf="@id/car_ui_list_item_action_container" + app:layout_constraintTop_toTopOf="@id/car_ui_list_item_action_container" /> + + <FrameLayout + android:id="@+id/car_ui_list_item_action_container" + android:layout_width="wrap_content" + android:layout_height="0dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@+id/car_ui_list_item_end_guideline" + app:layout_constraintTop_toTopOf="parent"> + + <Switch + android:id="@+id/car_ui_list_item_switch_widget" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" /> + + <CheckBox + android:id="@+id/car_ui_list_item_checkbox_widget" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" /> + + <RadioButton + android:id="@+id/car_ui_list_item_radio_button_widget" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" /> + + <ImageView + android:id="@+id/car_ui_list_item_supplemental_icon" + android:layout_width="?templateHalfRowImageSize" + android:layout_height="?templateHalfRowImageSize" + android:layout_gravity="center" + android:scaleType="fitCenter" /> + </FrameLayout> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/car_ui_list_item_end_guideline" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_end="?templateHalfRowHorizontalPadding" /> + +</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/half_list_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/half_list_view.xml new file mode 100644 index 0000000..4618e1e --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/half_list_view.xml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.RowListView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:orientation="vertical" + android:clipChildren="true" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:markerAppearance="?templateListMarkerAppearance" + app:listUseCompactRowLayout="true"> + + <FrameLayout + android:id="@+id/progress_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:minHeight="?templateRowMinHeight" + android:focusable="true" + android:visibility="gone"> + <com.android.car.libraries.templates.host.view.widgets.common.CarProgressBar + android:id="@+id/progress" + style="?templateLoadingSpinnerStyle" + android:layout_width="?templateNavCardLargeImageSize" + android:layout_height="?templateNavCardLargeImageSize" + app:imageMinSize="?templateNavCardLargeImageSizeMin" + app:imageMaxSize="?templateNavCardLargeImageSizeMax" + android:layout_gravity="center" /> + </FrameLayout> + + <!-- A text view displaying a message when the list is empty. --> + <CarUiTextView + style="?templateRowListEmptyTextStyle" + android:id="@+id/list_no_items_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginTop="?templateHalfListPaddingVertical" + android:layout_marginBottom="?templateHalfListPaddingVertical" + android:layout_marginHorizontal="?templateHalfRowHorizontalPadding" + android:foreground="@drawable/no_content_view_focus_ring" /> + + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/list_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clickable="true" + android:clipToPadding="true" + android:paddingBottom="?templateHalfListBottomPadding" + app:carUiSize="small" + app:layoutStyle="linear" + app:enableDivider="true" /> + +</com.android.car.libraries.templates.host.view.widgets.common.RowListView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/header_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/header_view.xml new file mode 100644 index 0000000..3742510 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/header_view.xml @@ -0,0 +1,98 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- The header button icon is wrapped by a frame layout to define + the background layer with a focus selector and ripple effects etc. --> + <FrameLayout + android:id="@+id/header_button_container" + android:layout_width="?templateHeaderButtonContainerSize" + android:layout_height="?templateHeaderButtonContainerSize" + android:layout_marginStart="?templateHeaderButtonStartSpacing" + android:addStatesFromChildren="true" + android:background="?templateHeaderButtonBackground" + android:visibility="gone" + android:clickable="true" + android:focusable="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" > + + <!-- The header action icon --> + <ImageView + android:id="@+id/header_icon" + android:layout_width="?templateHeaderButtonIconSize" + android:layout_height="?templateHeaderButtonIconSize" + android:layout_gravity="center" + android:scaleType="fitCenter" + android:maxWidth="?templateHeaderButtonContainerSize" + android:maxHeight="?templateHeaderButtonContainerSize" + tools:ignore="ContentDescription"/> + </FrameLayout> + + <!-- The header title --> + <CarUiTextView + android:id="@+id/header_title" + android:layout_height="wrap_content" + android:layout_width="0dp" + android:layout_marginStart="?templateHeaderTextStartSpacing" + android:layout_marginEnd="?templateHeaderTextEndSpacing" + android:layout_marginVertical="?templateHeaderTextVerticalSpacing" + android:maxLines="1" + android:ellipsize="end" + android:textAlignment="textStart" + android:gravity="center_vertical|start" + android:textAppearance="?templateHeaderTextStyle" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toEndOf="@id/header_button_container" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintEnd_toStartOf="@id/refresh_button_container" + app:layout_goneMarginStart="?templateHeaderTextNoIconStartSpacing" /> + + <!-- The optional refresh icon is wrapped by a frame layout to define + the background layer with a focus selector and ripple effects etc. --> + <FrameLayout + android:id="@+id/refresh_button_container" + android:layout_width="?templateHeaderButtonContainerSize" + android:layout_height="?templateHeaderButtonContainerSize" + android:layout_marginStart="?templateHeaderTextEndSpacing" + android:addStatesFromChildren="true" + android:background="?templateHeaderButtonBackground" + android:visibility="gone" + android:clickable="true" + android:focusable="true" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" > + + <!-- The refresh icon --> + <ImageView + android:id="@+id/refresh_icon" + android:layout_width="?templateHeaderButtonIconSize" + android:layout_height="?templateHeaderButtonIconSize" + android:layout_gravity="center" + android:scaleType="fitCenter" + android:maxWidth="?templateHeaderButtonContainerSize" + android:maxHeight="?templateHeaderButtonContainerSize" + tools:ignore="ContentDescription"/> + </FrameLayout> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/input_sign_in_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/input_sign_in_view.xml new file mode 100644 index 0000000..61b800f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/input_sign_in_view.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<com.android.car.libraries.templates.host.view.widgets.common.InputSignInView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + style="?templateSignInInputViewStyle" + android:orientation="vertical" > + + <!-- + Use a 0x0 drawable for textSelectHandle; null drawable crashes, so does + transparent color. Also set textCursorDrawable to null because this forces + Android to render a cursor using the text color instead of not rendering + one at all. + --> + <com.android.car.libraries.templates.host.view.widgets.common.CarEditText + android:id="@+id/input_sign_in_box" + style="?templateEditTextStyle" + android:inputType="text" + android:imeOptions="actionGo" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:drawablePadding="@dimen/template_padding_1" + android:focusable="true" + android:focusableInTouchMode="false" + android:textCursorDrawable="@null" + android:textSelectHandle="@drawable/empty" + tools:ignore="RtlHardcoded,SpUsage" /> + + <CarUiTextView + android:id="@+id/input_sign_in_error_message" + style="?templateSignInErrorMessageStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:visibility="gone" /> + +</com.android.car.libraries.templates.host.view.widgets.common.InputSignInView>
\ No newline at end of file diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/map_action_strip_view_floating.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/map_action_strip_view_floating.xml new file mode 100644 index 0000000..fd77064 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/map_action_strip_view_floating.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- +An action strip with floating buttons that anchors to the top right of the +screen, meant to be used in conjunction with half-screen, card-style templates. + +IMPORTANT: parents of this view should have clipChildren set to false so that +the shadows don't get clipped. +--> +<com.android.car.libraries.templates.host.view.widgets.common.ActionStripView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:focusable="false" + android:visibility="gone" + android:clipChildren="false" + app:fabAppearance="?templateActionStripFabAppearance" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintBottom_toBottomOf="parent"> + + <LinearLayout + android:id="@+id/action_strip_touch_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="?templateActionStripPadding" + android:orientation="horizontal"> + <LinearLayout + android:id="@+id/action_strip_container_secondary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clipChildren="false" + android:layout_marginEnd="?templateActionStripButtonMargin" + android:layout_gravity="center_horizontal|end" + android:orientation="vertical" /> + + <LinearLayout + android:id="@+id/action_strip_container" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clipChildren="false" + android:layout_gravity="center_horizontal|end" + android:orientation="vertical" /> + </LinearLayout> +</com.android.car.libraries.templates.host.view.widgets.common.ActionStripView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/pan_overlay.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/pan_overlay.xml new file mode 100644 index 0000000..31384e8 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/pan_overlay.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<com.android.car.libraries.templates.host.view.widgets.common.PanOverlayView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + <ImageView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:src="@drawable/ic_pan_overlay" + tools:ignore="ContentDescription" /> + +</com.android.car.libraries.templates.host.view.widgets.common.PanOverlayView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/pin_sign_in_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/pin_sign_in_view.xml new file mode 100644 index 0000000..663fce3 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/pin_sign_in_view.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<com.android.car.libraries.templates.host.view.widgets.common.PinSignInView + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?templateSignInPinBackground" + android:padding="?templateSignInPinPadding" + android:gravity="center"> + <CarUiTextView + android:id="@+id/pin_text" + style="?templateSignInPinTextStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" /> +</com.android.car.libraries.templates.host.view.widgets.common.PinSignInView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/qr_code_sign_in_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/qr_code_sign_in_view.xml new file mode 100644 index 0000000..7b9ffb3 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/qr_code_sign_in_view.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<com.android.car.libraries.templates.host.view.widgets.common.QRCodeSignInView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + tools:ignore="Overdraw"> + <ImageView + android:id="@+id/qr_code_view" + android:layout_width="?templateSignInQRCodeImageWidth" + android:layout_height="?templateSignInQRCodeImageWidth" + android:layout_gravity="center" + android:scaleType="fitCenter" + android:tint="?android:attr/textColorPrimary" + tools:ignore="ContentDescription" /> +</com.android.car.libraries.templates.host.view.widgets.common.QRCodeSignInView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/row_section_header_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/row_section_header_view.xml new file mode 100644 index 0000000..462c7fa --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/row_section_header_view.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<CarUiTextView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/row_section_header" + android:layout_width="match_parent" + android:layout_height="wrap_content" + style="?templateRowSectionHeaderStyle"/> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/sign_in_button_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/sign_in_button_view.xml new file mode 100644 index 0000000..0f343ae --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/layout/sign_in_button_view.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.common.ActionButtonView + xmlns:android="http://schemas.android.com/apk/res/android" + android:clickable="true" + android:focusable="true" + android:layout_width="wrap_content" + android:layout_height="?templateActionButtonHeight" + style="?templateSignInProviderSignInButtonStyle"> + + <!-- A container for the different optional parts of an action. --> + <LinearLayout + android:id="@+id/action_container" + android:layout_gravity="center" + android:orientation="horizontal" + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> +</com.android.car.libraries.templates.host.view.widgets.common.ActionButtonView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/values/attrs.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/values/attrs.xml new file mode 100644 index 0000000..5c7d401 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/values/attrs.xml @@ -0,0 +1,161 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <declare-styleable name="BleedingCardView"> + <attr name="cardRadius" format="dimension"/> + <attr name="cardBackgroundColor" format="color"/> + <attr name="cardTextColor" format="color"/> + + <!-- The colors used if there is not enough contrast ratio between + cardBackgroundColor and cardTextColor. --> + <attr name="cardFallbackDarkBackgroundColor" format="color" /> + <attr name="cardFallbackLightBackgroundColor" format="color" /> + + <attr name="cardBorderWidth" format="dimension"/> + <attr name="cardBorderColor" format="color" /> + <attr name="cardMinWidth" format="dimension" /> + <attr name="cardMaxWidth" format="dimension" /> + + <!-- The OEM-defined card width. + The value set by the OEM cannot be larger than cardOemMaxWidth. --> + <attr name="cardOemWidth" format="dimension" /> + <attr name="cardOemMaxWidth" format="dimension" /> + + <!-- The card width fraction in comparison to the parent view. + Zero means we use the layout_width, not the fraction. --> + <attr name="cardWidthFraction" format="float" /> + </declare-styleable> + + <declare-styleable name="ListView"> + <!-- The fraction of the screen width that the list will take, up to the max + defined by `listMaxWidth`. A negative value indicates that the list + does ot use an adaptive width. --> + <attr name="listWidthFraction" format="float"/> + + <!-- The maximum width a row list will use regardless of the + screen width. --> + <attr name="listMaxWidth" format="dimension" /> + + <!-- The width of the scrollbar next to the list. --> + <attr name="listScrollbarWidth" format="float"/> + + <!-- The start padding of the list without a scrollbar. --> + <attr name="listNoScrollBarStartPadding" format="dimension"/> + + <!-- Whether to show the scrollbar divider. --> + <attr name="listShowScrollbarDivider" format="boolean"/> + </declare-styleable> + + <declare-styleable name="RowListView"> + <!-- Whether to show the compact row layout. --> + <attr name="listUseCompactRowLayout" format="boolean"/> + </declare-styleable> + + <declare-styleable name="PlaceMarker"> + <!-- Appearance of the map markers. --> + <attr name="markerAppearance" format="reference" /> + </declare-styleable> + + <declare-styleable name="MarkerAppearance"> + <!-- Colors of the POI marker. --> + <attr name="markerDefaultBackgroundColor" format="color" /> + + <!-- Color that should be used for the content if it has a default bg --> + <attr name="markerDefaultContentColor" format="color" /> + + <!-- Color that should be used for the content if it has a custom bg --> + <attr name="markerCustomBackgroundContentColor" format="color" /> + + <!-- Color that should be used for the border if it is a default bg --> + <attr name="markerDefaultBorderColor" format="color" /> + + <!-- Color that should be used for the border if it is a custom bg --> + <attr name="markerCustomBorderColor" format="color" /> + + <attr name="markerPointerWidth" format="dimension" /> + <attr name="markerPointerHeight" format="dimension" /> + <attr name="markerStroke" format="dimension" /> + <attr name="markerCornerRadius" format="dimension" /> + <attr name="markerPadding" format="dimension" /> + + <!-- Colors of the anchor marker. --> + <attr name="anchorDefaultBackgroundColor" format="color" /> + <attr name="anchorBorderColor" format="color" /> + <attr name="anchorDotColor" format="color" /> + + <!-- The following android attributes are used for the marker label style. --> + <attr name="android:textSize" /> + <attr name="android:fontFamily" /> + <attr name="android:textStyle" /> + + <!-- The size of the label within the marker. --> + <attr name="markerTextHorizontalPadding" format="dimension" /> + <attr name="markerIconSize" format="dimension" /> + <attr name="markerImageSize" format="dimension" /> + <attr name="markerImageCornerRadius" format="dimension" /> + + <!-- The size of the marker icon in the list. --> + <attr name="markerListIconSize" format="dimension" /> + </declare-styleable> + + <declare-styleable name="ActionStripView"> + <!-- Appearance of the action strip fabs. --> + <attr name="fabAppearance" format="reference" /> + </declare-styleable> + + <!-- Styleable for different attributes to configure what an action strip FAB should look like. --> + <declare-styleable name="FabAppearance"> + <!-- The color that should be used for contents (icon+label) inside the FAB. --> + <attr name="fabDefaultContentColor" format="color" /> + </declare-styleable> + + <!-- Styleable for configuring the action button --> + <declare-styleable name="ActionButtonView"> + <!-- Specifies the maxEms value for the action button text. Needed to customize the maxEms value for some action buttons --> + <attr name="textMaxEms" format="integer"/> + </declare-styleable> + + <!-- Styleable for configuring the car image view --> + <declare-styleable name="CarImageView"> + <!-- The minimum image width. --> + <attr name="imageMinWidth" format="dimension" /> + + <!-- The maximum image width. --> + <attr name="imageMaxWidth" format="dimension" /> + + <!-- The minimum image height. --> + <attr name="imageMinHeight" format="dimension" /> + + <!-- The maximum image height. --> + <attr name="imageMaxHeight" format="dimension" /> + </declare-styleable> + + <!-- Styleable for configuring the car progress bar --> + <declare-styleable name="CarProgressBar"> + <!-- The minimum image size. --> + <attr name="imageMinSize" format="dimension" /> + + <!-- The maximum image size. --> + <attr name="imageMaxSize" format="dimension" /> + </declare-styleable> + + <!-- Custom error state to be used in edit boxes or other components that support this state --> + <declare-styleable name="ErrorState"> + <attr name="state_error" format="boolean"/> + </declare-styleable> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/values/integers.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/values/integers.xml new file mode 100644 index 0000000..ac16615 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/res/values/integers.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <integer name="action_strip_animation_duration_millis">250</integer> +</resources> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/ActionButtonListViewHelper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/ActionButtonListViewHelper.java new file mode 100644 index 0000000..7558316 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/ActionButtonListViewHelper.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.templates.host.view.widgets.common.testing; + +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonListView; +import com.android.car.libraries.templates.host.view.widgets.common.ActionButtonView; + +/** Test helper for the action button list view. */ +public class ActionButtonListViewHelper { + private static final int LAYOUT_WIDTH = 400; + private static final int LAYOUT_HEIGHT = 600; + + private final ViewGroup mActionButtonListView; + + public ActionButtonListViewHelper(ViewGroup actionButtonListView) { + mActionButtonListView = actionButtonListView; + } + + /** Force a measure and layout for the action strip. */ + public void measureAndLayout() { + mActionButtonListView.measure( + MeasureSpec.makeMeasureSpec(LAYOUT_WIDTH, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(LAYOUT_HEIGHT, MeasureSpec.EXACTLY)); + mActionButtonListView.layout(0, 0, LAYOUT_WIDTH, LAYOUT_HEIGHT); + } + + /** Returns a {@link ActionButtonView} at {@code index} in the {@code mActionButtonListView} */ + public ActionButtonView getAction(int index) { + return (ActionButtonView) mActionButtonListView.getChildAt(index); + } + + /** Returns an {@link ActionButtonListView} instance of the {@code mActionButtonListView} */ + public ActionButtonListView getActionButtonListView() { + return (ActionButtonListView) mActionButtonListView; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/ActionStripHelper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/ActionStripHelper.java new file mode 100644 index 0000000..ebee699 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/ActionStripHelper.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common.testing; + +import static android.view.View.VISIBLE; + +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import com.android.car.libraries.templates.host.view.widgets.common.FabView; +import java.util.ArrayList; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Test helper for the action strip. */ +public class ActionStripHelper { + private static final int LAYOUT_WIDTH = 400; + private static final int LAYOUT_HEIGHT = 600; + + private final ViewGroup mActionStripView; + + public ActionStripHelper(ViewGroup actionStripView) { + mActionStripView = actionStripView; + } + + /** Force a measure and layout for the action strip. */ + public void measureAndLayout() { + mActionStripView.measure( + MeasureSpec.makeMeasureSpec(LAYOUT_WIDTH, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(LAYOUT_HEIGHT, MeasureSpec.EXACTLY)); + mActionStripView.layout(0, 0, LAYOUT_WIDTH, LAYOUT_HEIGHT); + } + + /** Returns a {@link List} of {@link FabView}s from the action strip. */ + @Nullable + public List<FabView> getFabViews() { + List<FabView> views = new ArrayList<>(); + for (int i = 0; i < mActionStripView.getChildCount(); i++) { + FabView fabView = (FabView) mActionStripView.getChildAt(i); + if (fabView.getVisibility() == VISIBLE) { + views.add((FabView) mActionStripView.getChildAt(i)); + } + } + return views; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/GridContentViewHelper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/GridContentViewHelper.java new file mode 100644 index 0000000..0aef733 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/GridContentViewHelper.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common.testing; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.LayoutManager; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.android.car.libraries.templates.host.view.widgets.common.GridAdapter; +import com.android.car.libraries.templates.host.view.widgets.common.GridItemView; +import com.android.car.libraries.templates.host.view.widgets.common.GridRowWrapper; +import com.android.car.libraries.templates.host.view.widgets.common.GridView; +import com.android.car.libraries.templates.host.view.widgets.common.GridWrapper; +import com.android.car.ui.recyclerview.CarUiRecyclerView; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** Test helper for {@link ContentView} that has the {@link GridWrapper} content set. */ +public class GridContentViewHelper { + private static final int LAYOUT_WIDTH = 400; + private static final int LAYOUT_HEIGHT = 600; + + private final ContentView mContentView; + + public GridContentViewHelper(ContentView contentView) { + mContentView = contentView; + } + + /** Force a measure and layout for the content view. */ + public void measureAndLayout() { + CarUiRecyclerView pagedListView = getRecyclerView(); + if (pagedListView != null) { + pagedListView + .getView() + .measure( + MeasureSpec.makeMeasureSpec(LAYOUT_WIDTH, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(LAYOUT_HEIGHT, MeasureSpec.EXACTLY)); + pagedListView.getView().layout(0, 0, LAYOUT_WIDTH, LAYOUT_HEIGHT); + } + } + + /** Returns the {@link RecyclerView} from the content view. */ + @Nullable + public CarUiRecyclerView getRecyclerView() { + GridView gridView = getGridView(); + if (gridView == null) { + return null; + } + return (CarUiRecyclerView) gridView.findViewById(R.id.grid_paged_list_view); + } + + /** Returns a {@link List} of {@link GridRowWrapper}s from the content view. */ + @Nullable + public List<GridRowWrapper> getGridRowWrappers() { + CarUiRecyclerView listView = getRecyclerView(); + if (listView != null) { + GridAdapter adapter = (GridAdapter) listView.getAdapter(); + if (adapter != null) { + return adapter.getRowWrappers(); + } + } + return null; + } + + /** Returns a specified {@link GridItemView} from the content view. */ + @Nullable + public GridItemView getGridItemView(int index) { + CarUiRecyclerView listView = getRecyclerView(); + if (listView == null) { + return null; + } + + return (GridItemView) listView.getRecyclerViewChildAt(index); + } + + /** Returns a specified {@link GridItemViewHelper} from the content view. */ + @Nullable + public GridItemViewHelper getGridItemViewHelper(int index) { + GridItemView gridItemView = getGridItemView(index); + if (gridItemView == null) { + return null; + } + return new GridItemViewHelper(gridItemView); + } + + @Nullable + private GridView getGridView() { + ViewGroup container = mContentView.findViewById(R.id.container); + if (container == null) { + return null; + } + return (GridView) container.getChildAt(0); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/GridItemViewHelper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/GridItemViewHelper.java new file mode 100644 index 0000000..2558b66 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/GridItemViewHelper.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common.testing; + +import static android.view.View.VISIBLE; + +import android.widget.ImageView; +import android.widget.ProgressBar; +import androidx.annotation.Nullable; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.GridItemView; +import com.android.car.ui.widget.CarUiTextView; + +/** Test helper for {@link GridItemView}. */ +public class GridItemViewHelper { + private final GridItemView mGridItemView; + + public GridItemViewHelper(GridItemView gridItemView) { + mGridItemView = gridItemView; + } + + /** Returns the title as a {@link String} for the {@link GridItemView}. */ + @Nullable + public String getTitle() { + return getTextById(R.id.grid_item_title); + } + + /** Returns the title as the raw {@link CharSequence} for the {@link GridItemView}. */ + @Nullable + public CharSequence getText() { + return getTextById(R.id.grid_item_text); + } + + @Nullable + private String getTextById(int id) { + CarUiTextView carUiTextView = mGridItemView.findViewById(id); + if (carUiTextView.getVisibility() == VISIBLE) { + CharSequence title = carUiTextView.getText(); + if (title != null) { + return title.toString(); + } + } + + return null; + } + + /** Returns the {@link ImageView} for the {@link GridItemView}. */ + @Nullable + public ImageView getImage() { + ImageView imageView = mGridItemView.findViewById(R.id.grid_item_image); + if (imageView.getVisibility() == VISIBLE) { + return imageView; + } + return null; + } + + /** Returns the {@link ProgressBar} for the {@link GridItemView}. */ + @Nullable + public ProgressBar getLoadingView() { + ProgressBar loadingView = mGridItemView.findViewById(R.id.grid_item_progress_bar); + if (loadingView.getVisibility() == VISIBLE) { + return loadingView; + } + return null; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/RowListContentViewHelper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/RowListContentViewHelper.java new file mode 100644 index 0000000..877649f --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/RowListContentViewHelper.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common.testing; + +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.Nullable; +import com.android.car.libraries.apphost.template.view.model.RowListWrapper; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.ContentView; +import com.android.car.libraries.templates.host.view.widgets.common.RowAdapter; +import com.android.car.libraries.templates.host.view.widgets.common.RowHolder; +import com.android.car.libraries.templates.host.view.widgets.common.RowListView; +import com.android.car.ui.recyclerview.CarUiRecyclerView; +import java.util.List; + +/** Test helper for {@link ContentView} that has the {@link RowListWrapper} content set. */ +public class RowListContentViewHelper { + private static final int LAYOUT_WIDTH = 400; + private static final int LAYOUT_HEIGHT = 600; + + private final ContentView mContentView; + + public RowListContentViewHelper(ContentView contentView) { + mContentView = contentView; + } + + /** Force a measure and layout on the given {@link ContentView}. */ + public void measureAndLayout() { + measureAndLayout(LAYOUT_WIDTH, LAYOUT_HEIGHT); + } + + /** Force a measure and layout on the given {@link ContentView} with given width and height. */ + public void measureAndLayout(int width, int height) { + RowListView pagedListView = getListView(); + if (pagedListView != null) { + pagedListView.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + pagedListView.layout(0, 0, width, height); + } + } + + /** Returns the {@link RowListView} from the content view. */ + @Nullable + public RowListView getListView() { + RowListView listView = getRowListView(); + if (listView == null) { + return null; + } + + CarUiRecyclerView recyclerView = listView.findViewById(R.id.list_view); + return (RowListView) recyclerView.getParent(); + } + + /** Returns a {@link List} of the {@link RowHolder}s in the content view. */ + @Nullable + public List<RowHolder> getRowHolders() { + RowListView listView = getListView(); + if (listView != null) { + RowAdapter adapter = listView.getAdapter(); + if (adapter != null) { + return adapter.getRowHolders(); + } + } + return null; + } + + /** Returns the row view for a given index from the content view. */ + @Nullable + public View getRowView(int index) { + return getListItemView(index, View.class); + } + + /** Returns the text of the given section header from the content view. */ + @Nullable + public String getSectionHeaderText(int index) { + View sectionHeaderView = getSectionHeaderView(index); + if (sectionHeaderView == null) { + return null; + } + TextView view = sectionHeaderView.findViewById(R.id.row_section_header); + return view != null ? view.getText().toString() : null; + } + + /** Returns the {@link TextView} of the given section header from the content view. */ + @Nullable + public View getSectionHeaderView(int index) { + return getListItemView(index, View.class); + } + + /** Returns a {@link RowViewHelper} for the given row index from the content view. */ + @Nullable + public RowViewHelper getRowViewHelper(int index) { + View rowView = getRowView(index); + if (rowView == null) { + return null; + } + return new RowViewHelper(rowView); + } + + @Nullable + private <T> T getListItemView(int index, Class<T> clazz) { + RowListView listView = getListView(); + if (listView != null) { + return clazz.cast(listView.getRecyclerView().getRecyclerViewChildAt(index)); + } + return null; + } + + @Nullable + private RowListView getRowListView() { + ViewGroup container = mContentView.findViewById(R.id.container); + if (container == null) { + return null; + } + return (RowListView) container.getChildAt(0); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/RowViewHelper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/RowViewHelper.java new file mode 100644 index 0000000..4fb4478 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/RowViewHelper.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.common.testing; + +import static android.view.View.VISIBLE; + +import android.text.Spanned; +import android.text.SpannedString; +import android.view.View; +import android.widget.ImageView; +import android.widget.RadioButton; +import android.widget.Switch; +import androidx.annotation.Nullable; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Test helper for rows of {@code RowListView}. */ +public class RowViewHelper { + private final View mRowView; + + public RowViewHelper(View rowView) { + mRowView = rowView; + } + + /** Returns the title for the row as a {@link String}. */ + @Nullable + public String getTitle() { + CharSequence title = getTitleCharSequence(); + return title == null ? null : title.toString(); + } + + /** Get the title for the row as the direct {@link CharSequence}. */ + @Nullable + public CharSequence getTitleCharSequence() { + return ((CarUiTextView) mRowView.findViewById(R.id.car_ui_list_item_title)).getText(); + } + + /** Get a specified span in the title for a row. */ + @Nullable + public <T> T getTitleSpanAt(int spanIndex, Class<T> clazz) { + CharSequence charSequence = getTitleCharSequence(); + if (charSequence == null) { + return null; + } + return getSpanAt(charSequence, spanIndex, clazz); + } + + /** Returns {@code true} if the radio button for the row is selected. */ + public boolean isRadioButtonSelected() { + RadioButton radioButton = mRowView.findViewById(R.id.car_ui_list_item_radio_button_widget); + return radioButton.isChecked(); + } + + /** Returns the lines for the body rows. */ + @Nullable + public List<String> getTextLines() { + CarUiTextView bodyTextView = getBodyTextView(); + if (bodyTextView == null) { + return null; + } + + String[] lines = bodyTextView.getText().toString().split("\n"); + return Arrays.asList(lines); + } + + /** Returns the specified span within the row text. */ + @Nullable + public <T> T getTextSpanAt(int textIndex, int spanIndex, Class<T> clazz) { + CharSequence text = getTextAt(textIndex); + if (text != null) { + return getSpanAt(text, spanIndex, clazz); + } + return null; + } + + /** Returns a specific the text for a particular view index within the row. */ + @Nullable + public CharSequence getTextAt(int index) { + return getBodyTextLine(index); + } + + /** Returns the max number of lines for a given view within the row. */ + public int getTextMaxLinesAt(int index) { + CarUiTextView carUiTextView = getBodyTextView(); + return carUiTextView == null ? -1 : carUiTextView.getMaxLines(); + } + + /** Returns the image of the caret for the row. */ + @Nullable + public ImageView getCaret() { + return getImageView(R.id.car_ui_list_item_supplemental_icon); + } + + /** Returns the secondary text view for the row if visible. */ + @Nullable + public CarUiTextView getBodyTextView() { + CarUiTextView bodyTextView = mRowView.findViewById(R.id.car_ui_list_item_body); + if (bodyTextView.getVisibility() == VISIBLE) { + return bodyTextView; + } + return null; + } + + /** Returns the radio button for the row if visible. */ + @Nullable + public RadioButton getRadioButton() { + RadioButton radioButton = mRowView.findViewById(R.id.car_ui_list_item_radio_button_widget); + if (radioButton.getVisibility() == VISIBLE) { + return radioButton; + } + return null; + } + + /** Returns the image for the row if visible. */ + @Nullable + public ImageView getImage() { + return getImageView(R.id.car_ui_list_item_icon); + } + + /** Returns the {@link Switch} view for the row. */ + @Nullable + public Switch getToggle() { + Switch toggle = mRowView.findViewById(R.id.car_ui_list_item_switch_widget); + if (toggle.getVisibility() == VISIBLE) { + return toggle; + } + return null; + } + + /** Returns the view containing the row elements. */ + public View getContainer() { + return mRowView; + } + + /** Returns the view that acts as a touch interceptor. */ + public View getTouchInterceptor() { + return mRowView.findViewById(R.id.car_ui_list_item_touch_interceptor); + } + + @Nullable + private ImageView getImageView(int id) { + ImageView imageView = mRowView.findViewById(id); + if (imageView.getVisibility() == VISIBLE) { + return imageView; + } + return null; + } + + @Nullable + private CharSequence getBodyTextLine(int index) { + CarUiTextView bodyTextView = getBodyTextView(); + if (bodyTextView == null) { + return null; + } + CharSequence bodyText = bodyTextView.getText(); + CharSequence[] lines = split(bodyText, "\n"); + return lines[index]; + } + + @Nullable + private <T> T getSpanAt(CharSequence charSequence, int spanIndex, Class<T> clazz) { + SpannedString ss = (SpannedString) charSequence; + T[] spans = ss.getSpans(0, charSequence.length(), clazz); + if (spans == null || spanIndex > spans.length - 1) { + return null; + } + return spans[spanIndex]; + } + + private static CharSequence[] split(CharSequence charSequence, String regex) { + // A short-cut for non-spanned strings. + if (!(charSequence instanceof Spanned)) { + return charSequence.toString().split(regex); + } + + // Hereafter, emulate String.split for CharSequence. + ArrayList<CharSequence> sequences = new ArrayList<>(); + Matcher matcher = Pattern.compile(regex).matcher(charSequence); + int nextStart = 0; + boolean matched = false; + while (matcher.find()) { + sequences.add(charSequence.subSequence(nextStart, matcher.start())); + nextStart = matcher.end(); + matched = true; + } + if (!matched) { + return new CharSequence[] {charSequence}; + } + sequences.add(charSequence.subSequence(nextStart, charSequence.length())); + return sequences.toArray(new CharSequence[0]); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/TemplateViewHelper.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/TemplateViewHelper.java new file mode 100644 index 0000000..7efe1f4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/common/testing/TemplateViewHelper.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.templates.host.view.widgets.common.testing; + +import android.annotation.SuppressLint; +import com.android.car.libraries.apphost.view.AbstractTemplatePresenter; +import com.android.car.libraries.templates.host.view.TemplateView; + +/** Test helper for {@link TemplateView}. */ +public final class TemplateViewHelper { + + private static final int LAYOUT_WIDTH = 400; + private static final int LAYOUT_HEIGHT = 600; + + /** Force a measure and layout on the given {@link TemplateView}. */ + @SuppressLint("RestrictedApi") + public static void measureAndLayout(TemplateView templateView) { + templateView.measure(LAYOUT_WIDTH, LAYOUT_HEIGHT); + templateView.layout(0, 0, LAYOUT_WIDTH, LAYOUT_HEIGHT); + + // Robolectric creates views without giving it size, causing the view to fail to take input + // focus. + // Set the content view size and restore focus. + AbstractTemplatePresenter presenter = + (AbstractTemplatePresenter) templateView.getCurrentPresenter(); + if (presenter != null) { + presenter.restoreFocus(); + } + } + + private TemplateViewHelper() {} +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/CompactStepView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/CompactStepView.java new file mode 100644 index 0000000..e262778 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/CompactStepView.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.templates.host.view.widgets.navigation; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.navigation.model.Maneuver; +import androidx.car.app.navigation.model.Step; +import com.android.car.libraries.apphost.common.CarColorUtils; +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.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.CarUiTextUtils; +import com.android.car.ui.widget.CarUiTextView; + +/** + * A view that displays a compact view of a navigation maneuver. + * + * <p>For example it could show just the maneuver description or a description and an icon. + */ +public class CompactStepView extends LinearLayout { + private ImageView mTurnSymbolView; + private CarUiTextView mDescriptionText; + @Nullable private Step mStep; + private int mDescriptionTextDefaultTextColor; + + public CompactStepView(Context context) { + this(context, null); + } + + public CompactStepView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CompactStepView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public CompactStepView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTurnSymbolView = findViewById(R.id.compact_turn_symbol); + mDescriptionText = findViewById(R.id.compact_description_text); + + mDescriptionTextDefaultTextColor = mDescriptionText.getCurrentTextColor(); + } + + /** Sets the color of the texts in the view. */ + public void setTextColor(@ColorInt int textColor) { + mDescriptionText.setTextColor(textColor); + } + + /** Sets the colors of the texts in he view to their default colors. */ + public void setDefaultTextColor() { + mDescriptionText.setTextColor(mDescriptionTextDefaultTextColor); + } + + /** + * Sets the {@link Step} to be shown. + * + * <p>Setting a {@code null} steo will cause the view to be hidden. + */ + public void setStep( + TemplateContext templateContext, + @Nullable Step step, + CarTextParams carTextParams, + @ColorInt int cardBackgroundColor) { + L.v(LogTags.TEMPLATE, "Setting compact step view with step: %s", step); + + mStep = step; + if (step == null) { + setVisibility(GONE); + return; + } + Maneuver maneuver = step.getManeuver(); + CarIcon turnIcon = maneuver == null ? null : maneuver.getIcon(); + boolean shouldShowTurnIcon = + ImageUtils.setImageSrc( + templateContext, + turnIcon, + mTurnSymbolView, + ImageViewParams.builder() + .setBackgroundColor(cardBackgroundColor) + .setIgnoreAppTint( + !CarColorUtils.checkIconTintContrast( + templateContext, turnIcon, cardBackgroundColor)) + .build()); + mTurnSymbolView.setVisibility(shouldShowTurnIcon ? VISIBLE : GONE); + + CarTextParams.Builder paramsBuilder = + CarTextParams.builder(carTextParams).setBackgroundColor(cardBackgroundColor); + CarText cue = step.getCue(); + if (cue != null) { + paramsBuilder.setIgnoreAppIconTint( + !CarTextUtils.checkColorContrast(templateContext, cue, cardBackgroundColor)); + } + + mDescriptionText.setText( + CarUiTextUtils.fromCarText( + templateContext, cue, paramsBuilder.build(), mDescriptionText.getMaxLines())); + setVisibility(VISIBLE); + } + + @VisibleForTesting + @Nullable + public Step getStep() { + return mStep; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/DetailedStepView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/DetailedStepView.java new file mode 100644 index 0000000..29c9570 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/DetailedStepView.java @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.navigation; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.StyleableRes; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import androidx.car.app.model.Distance; +import androidx.car.app.navigation.model.Maneuver; +import androidx.car.app.navigation.model.Step; +import com.android.car.libraries.apphost.common.CarColorUtils; +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.view.common.CarTextParams; +import com.android.car.libraries.apphost.view.common.CarTextUtils; +import com.android.car.libraries.apphost.view.common.DistanceUtils; +import com.android.car.libraries.apphost.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.CarUiTextUtils; +import com.android.car.ui.widget.CarUiTextView; + +/** + * A view that displays a detailed navigation step. + * + * <p>This view tries to display all the elements of a {@link Step} and {@link Distance}. For + * example if available, it would show a turn icon, description and lanes image. It could be used + * with another view to show the next turn. + */ +public class DetailedStepView extends LinearLayout { + private ImageView mTurnSymbolView; + private CarUiTextView mDistanceText; + private CarUiTextView mDescriptionText; + private ImageView mLanesImageView; + private LinearLayout mTurnContainerView; + private FrameLayout mLanesImageContainerView; + private final int mNavCardPaddingVertical; + private final int mNavCardSmallPaddingVertical; + private int mDistanceTextDefaultTextColor; + private int mDescriptionTextDefaultTextColor; + + @Nullable private Step mStep; + @Nullable private Distance mDistance; + + public DetailedStepView(Context context) { + this(context, null); + } + + public DetailedStepView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public DetailedStepView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + @StyleableRes + final int[] themeAttrs = { + R.attr.templateNavCardPaddingVertical, + R.attr.templateNavCardSmallPaddingVertical + }; + TypedArray ta = context.obtainStyledAttributes(themeAttrs); + mNavCardPaddingVertical = ta.getDimensionPixelSize(0, 0); + mNavCardSmallPaddingVertical = ta.getDimensionPixelSize(1, 0); + ta.recycle(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTurnSymbolView = findViewById(R.id.turn_symbol); + mDistanceText = findViewById(R.id.distance_text); + mDescriptionText = findViewById(R.id.description_text); + mLanesImageView = findViewById(R.id.lanes_image); + mTurnContainerView = findViewById(R.id.turn_container); + mLanesImageContainerView = findViewById(R.id.lanes_image_container); + + mDistanceTextDefaultTextColor = mDistanceText.getCurrentTextColor(); + mDescriptionTextDefaultTextColor = mDescriptionText.getCurrentTextColor(); + } + + /** Sets the color of the texts in the view. */ + public void setTextColor(@ColorInt int textColor) { + mDistanceText.setTextColor(textColor); + mDescriptionText.setTextColor(textColor); + } + + /** Sets the colors of the texts in the view to their default colors. */ + public void setDefaultTextColor() { + mDistanceText.setTextColor(mDistanceTextDefaultTextColor); + mDescriptionText.setTextColor(mDescriptionTextDefaultTextColor); + } + + /** + * Sets the {@link Step} and {@link Distance} to be shown. + * + * <p>If the {@link Step} is {@code null} then the entire view is hidden. If the {@link Distance} + * is null then the just the distance text is hidden and the step is still shown. + */ + public void setStepAndDistance( + TemplateContext templateContext, + @Nullable Step step, + @Nullable Distance distance, + CarTextParams cueTextParams, + @ColorInt int cardBackgroundColor, + boolean hideLaneImages) { + L.v( + LogTags.TEMPLATE, + "Setting detailed step view with step: %s, and distance: %s", + step, + distance); + + mStep = step; + if (step == null) { + setVisibility(GONE); + return; + } + mDistance = distance; + Maneuver maneuver = step.getManeuver(); + CarIcon turnIcon = maneuver == null ? null : maneuver.getIcon(); + ImageViewParams turnIconParams = + ImageViewParams.builder() + .setBackgroundColor(cardBackgroundColor) + .setIgnoreAppTint( + !CarColorUtils.checkIconTintContrast( + templateContext, turnIcon, cardBackgroundColor)) + .build(); + boolean shouldShowTurnIcon = + ImageUtils.setImageSrc(templateContext, turnIcon, mTurnSymbolView, turnIconParams); + mTurnSymbolView.setVisibility(shouldShowTurnIcon ? VISIBLE : GONE); + + if (distance != null) { + mDistanceText.setText( + CarUiTextUtils.fromCharSequence( + templateContext, + DistanceUtils.convertDistanceToDisplayString(templateContext, distance), + mDistanceText.getMaxLines())); + mDistanceText.setVisibility(VISIBLE); + } else { + mDistanceText.setVisibility(GONE); + } + + CarText cue = step.getCue(); + if (cue == null || CarText.isNullOrEmpty(cue)) { + mDescriptionText.setVisibility(GONE); + } else { + // Ignore app icon tint if it does not pass color contrast check + cueTextParams = + CarTextParams.builder(cueTextParams) + .setBackgroundColor(cardBackgroundColor) + .setIgnoreAppIconTint( + !CarTextUtils.checkColorContrast(templateContext, cue, cardBackgroundColor)) + .build(); + mDescriptionText.setText( + CarUiTextUtils.fromCarText( + templateContext, cue, cueTextParams, mDescriptionText.getMaxLines())); + mDescriptionText.setVisibility(VISIBLE); + } + + CarIcon laneImage = step.getLanesImage(); + ImageViewParams laneImageParams = + ImageViewParams.builder() + .setBackgroundColor(cardBackgroundColor) + .setIgnoreAppTint( + !CarColorUtils.checkIconTintContrast( + templateContext, laneImage, cardBackgroundColor)) + .build(); + + boolean shouldShowLanesImage = + !hideLaneImages + && ImageUtils.setImageSrc(templateContext, laneImage, mLanesImageView, laneImageParams); + + int turnContainerBottomMargin; + if (shouldShowLanesImage) { + mLanesImageContainerView.setVisibility(VISIBLE); + + // If the lane image is present, apply the small internal padding between the turn + // container and the lane image. + turnContainerBottomMargin = mNavCardSmallPaddingVertical; + } else { + mLanesImageContainerView.setVisibility(GONE); + turnContainerBottomMargin = mNavCardPaddingVertical; + } + LinearLayout.LayoutParams layoutParams = + (LinearLayout.LayoutParams) mTurnContainerView.getLayoutParams(); + layoutParams.bottomMargin = turnContainerBottomMargin; + mTurnContainerView.setLayoutParams(layoutParams); + + setVisibility(VISIBLE); + } + + @VisibleForTesting + @Nullable + public Step getStep() { + return mStep; + } + + @VisibleForTesting + @Nullable + public Distance getDistance() { + return mDistance; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/MessageView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/MessageView.java new file mode 100644 index 0000000..1e7bccd --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/MessageView.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.libraries.templates.host.view.widgets.navigation; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.car.app.model.CarIcon; +import androidx.car.app.model.CarText; +import com.android.car.libraries.apphost.common.CarColorUtils; +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.view.common.ImageUtils; +import com.android.car.libraries.apphost.view.common.ImageViewParams; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.view.widgets.common.CarUiTextUtils; +import com.android.car.ui.widget.CarUiTextView; + +/** A view that displays a message with optional image and subtext. */ +public class MessageView extends LinearLayout { + private ImageView mImageView; + private CarUiTextView mTitleView; + private CarUiTextView mTextView; + private int mTitleDefaultTextColor; + private int mTextDefaultTextColor; + + public MessageView(@NonNull Context context) { + this(context, null); + } + + public MessageView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + @SuppressWarnings({"argument.type.incompatible"}) + public MessageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mImageView = findViewById(R.id.message_image); + mTitleView = findViewById(R.id.message_title); + mTextView = findViewById(R.id.message_text); + + mTitleDefaultTextColor = mTitleView.getCurrentTextColor(); + mTextDefaultTextColor = mTextView.getCurrentTextColor(); + } + + /** Sets the color of the texts in the view. */ + public void setTextColor(@ColorInt int textColor) { + mTitleView.setTextColor(textColor); + mTextView.setTextColor(textColor); + } + + /** Sets the colors of the texts in the view to their default colors. */ + public void setDefaultTextColor() { + mTitleView.setTextColor(mTitleDefaultTextColor); + mTextView.setTextColor(mTextDefaultTextColor); + } + + /** Sets the title, image and text content the view. */ + public void setMessage( + TemplateContext templateContext, + @Nullable CarIcon image, + CarText title, + @Nullable CarText text, + @ColorInt int cardBackgroundColor) { + L.v( + LogTags.TEMPLATE, + "Setting message view with message: %s secondary: %s image: %s", + title, + text, + image); + + boolean shouldShowImage = + ImageUtils.setImageSrc( + templateContext, + image, + mImageView, + ImageViewParams.builder() + .setBackgroundColor(cardBackgroundColor) + .setIgnoreAppTint( + !CarColorUtils.checkIconTintContrast( + templateContext, image, cardBackgroundColor)) + .build()); + mImageView.setVisibility(shouldShowImage ? VISIBLE : GONE); + + mTitleView.setText( + CarUiTextUtils.fromCarText(templateContext, title, mTitleView.getMaxLines())); + + mTextView.setText(CarUiTextUtils.fromCarText(templateContext, text, mTextView.getMaxLines())); + mTextView.setVisibility(!CarText.isNullOrEmpty(text) ? VISIBLE : GONE); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/ProgressView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/ProgressView.java new file mode 100644 index 0000000..bb07bdd --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/ProgressView.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.templates.host.view.widgets.navigation; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.util.AttributeSet; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.android.car.libraries.templates.host.R; + +/** A view that displays a progress indicator. */ +public class ProgressView extends LinearLayout { + private ProgressBar mProgressBar; + + public ProgressView(@NonNull Context context) { + this(context, null); + } + + public ProgressView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + @SuppressWarnings({"argument.type.incompatible"}) + public ProgressView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mProgressBar = findViewById(R.id.progress_indicator); + } + + /** Sets the color of the progress indicator. */ + public void setColor(@ColorInt int color) { + mProgressBar.setIndeterminateTintList(ColorStateList.valueOf(color)); + } + + /** Sets the color of the progress indicator to its default color. */ + public void setDefaultColor() { + mProgressBar.setIndeterminateTintList(null); + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/TravelEstimateView.java b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/TravelEstimateView.java new file mode 100644 index 0000000..dd9e99a --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/TravelEstimateView.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.templates.host.view.widgets.navigation; + +import static android.graphics.Color.TRANSPARENT; + +import android.content.Context; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; +import android.util.AttributeSet; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.model.DateTimeWithZone; +import androidx.car.app.model.Distance; +import androidx.car.app.navigation.model.TravelEstimate; +import com.android.car.libraries.apphost.common.CarColorUtils; +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.DateTimeUtils; +import com.android.car.libraries.apphost.view.common.DistanceUtils; +import com.android.car.libraries.templates.host.R; +import com.android.car.ui.widget.CarUiTextView; +import java.time.Duration; +import java.time.ZoneId; +import java.util.ArrayList; + +/** + * A view that displays a travel estimate for the navigation trip. + * + * <p>This view tries to display elements from the {@link TravelEstimate} data. For example if + * available, it would show the estimated time of arrival and distance to destination. + */ +public class TravelEstimateView extends LinearLayout { + private static final String INTERPUNCT = "\u00b7"; + private static final String TIME_AND_DISTANCE_SEPARATOR = " " + INTERPUNCT + " "; + + private CarUiTextView mArrivalTimeText; + private CarUiTextView mTimeAndDistanceText; + @Nullable private TravelEstimate mTravelEstimate; + + public TravelEstimateView(Context context) { + this(context, null); + } + + public TravelEstimateView(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public TravelEstimateView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public TravelEstimateView( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mArrivalTimeText = findViewById(R.id.arrival_time_text); + mTimeAndDistanceText = findViewById(R.id.time_and_distance_text); + } + + /** Sets the {@link TravelEstimate} or hides the view if set to {@code null} */ + @SuppressWarnings("NewApi") // java.time APIs are OK through de-sugaring. + public void setTravelEstimate( + TemplateContext templateContext, @Nullable TravelEstimate travelEstimate) { + L.v(LogTags.TEMPLATE, "Setting travel estimate view: %s", travelEstimate); + + mTravelEstimate = travelEstimate; + if (travelEstimate == null) { + setVisibility(GONE); + return; + } + + // Display the arrival time. + DateTimeWithZone arrivalTime = travelEstimate.getArrivalTimeAtDestination(); + if (arrivalTime != null) { + mArrivalTimeText.setText( + DateTimeUtils.formatArrivalTimeString( + templateContext, arrivalTime, ZoneId.systemDefault())); + } else { + // This shouldn't happen since the API should enforce a non-null arrival time. + mArrivalTimeText.setText(new ArrayList<>()); + } + + // Display the remaining trip time. + // The destination travel estimate's duration should not be unknown, but if it is, use an + // empty + // string. + long remainingTimeSeconds = travelEstimate.getRemainingTimeSeconds(); + String timeString = + remainingTimeSeconds == TravelEstimate.REMAINING_TIME_UNKNOWN + ? "" + : DateTimeUtils.formatDurationString( + templateContext, Duration.ofSeconds(remainingTimeSeconds)); + Distance distance = travelEstimate.getRemainingDistance(); + String distanceString; + if (distance != null) { + distanceString = DistanceUtils.convertDistanceToDisplayString(templateContext, distance); + } else { + distanceString = ""; + L.w(LogTags.TEMPLATE, "Remaining distance for the travel estimate is expected but not set"); + } + String timeAndDistanceString = timeString + TIME_AND_DISTANCE_SEPARATOR + distanceString; + + // If we have a valid custom text color, use it. + SpannableString timeAndDistanceSpannable = new SpannableString(timeAndDistanceString); + + @ColorInt + int remainingTimeColor = + CarColorUtils.resolveColor( + templateContext, + travelEstimate.getRemainingTimeColor(), + /* isDark= */ false, + /* defaultColor= */ TRANSPARENT, + CarColorConstraints.STANDARD_ONLY); + setStringColorSpan(remainingTimeColor, timeAndDistanceSpannable, 0, timeString.length()); + + @ColorInt + int remainingDistanceColor = + CarColorUtils.resolveColor( + templateContext, + travelEstimate.getRemainingDistanceColor(), + /* isDark= */ false, + /* defaultColor= */ TRANSPARENT, + CarColorConstraints.STANDARD_ONLY); + setStringColorSpan( + remainingDistanceColor, + timeAndDistanceSpannable, + timeString.length() + TIME_AND_DISTANCE_SEPARATOR.length(), + timeAndDistanceString.length()); + + mTimeAndDistanceText.setText(timeAndDistanceSpannable); + } + + /** Sets a color span in the given {@link SpannableString}. */ + private static void setStringColorSpan( + @ColorInt int color, SpannableString spannable, int start, int end) { + if (color != TRANSPARENT) { + spannable.setSpan( + new ForegroundColorSpan(color), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + @VisibleForTesting + @Nullable + public TravelEstimate getTravelEstimate() { + return mTravelEstimate; + } +} diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/compact_step_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/compact_step_view.xml new file mode 100644 index 0000000..27f04d4 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/compact_step_view.xml @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.navigation.CompactStepView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingVertical="?templateNavCardSmallPaddingVertical" + android:paddingHorizontal="?templateNavCardPaddingHorizontal" + android:orientation="horizontal" + android:gravity="center_vertical"> + + <!-- An image showing the turn icon on the left of the view. --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/compact_turn_symbol" + android:layout_width="?templateNavCardSmallImageSize" + android:layout_height="?templateNavCardSmallImageSize" + app:imageMinWidth="?templateNavCardSmallImageSizeMin" + app:imageMaxWidth="?templateNavCardSmallImageSizeMax" + app:imageMinHeight="?templateNavCardSmallImageSizeMin" + app:imageMaxHeight="?templateNavCardSmallImageSizeMax" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + android:layout_gravity="center" + tools:ignore="ContentDescription" /> + + <!-- A text view displaying the description of the step, e.g. "Boggle St". --> + <CarUiTextView + android:id="@+id/compact_description_text" + style="?templateRoutingCompactDescriptionStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/template_steps_card_image_to_text_spacing_vertical" + android:layout_gravity="start|center" /> +</com.android.car.libraries.templates.host.view.widgets.navigation.CompactStepView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/detailed_step_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/detailed_step_view.xml new file mode 100644 index 0000000..c35eab7 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/detailed_step_view.xml @@ -0,0 +1,99 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.navigation.DetailedStepView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <!-- A container for the turn icon, the distance, and the description. + We use this container so that we can apply the right margins to this + content. --> + <LinearLayout + android:id="@+id/turn_container" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="?templateNavCardPaddingHorizontal" + android:layout_marginVertical="?templateNavCardPaddingVertical"> + + <!-- The top row showing the turn icon and the distance to it. --> + <LinearLayout + android:orientation="horizontal" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical"> + + <!-- An image showing the turn icon on the top left of the view. --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/turn_symbol" + android:layout_width="?templateNavCardLargeImageSize" + android:layout_height="?templateNavCardLargeImageSize" + app:imageMinWidth="?templateNavCardLargeImageSizeMin" + app:imageMaxWidth="?templateNavCardLargeImageSizeMax" + app:imageMinHeight="?templateNavCardLargeImageSizeMin" + app:imageMaxHeight="?templateNavCardLargeImageSizeMax" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + tools:ignore="ContentDescription" + android:layout_marginEnd="?templateRoutingStepsCardIconToDistanceSpacingHorizontal" /> + + <!-- A text view next to the turn image on top showing the distance to the + next step. --> + <CarUiTextView + android:id="@+id/distance_text" + style="?templateRoutingDistanceStyle" + android:layout_gravity="center_vertical|start" + android:layout_width="wrap_content" + android:layout_height="wrap_content" /> + </LinearLayout> + + <!-- A text view displaying the description of the step, e.g. "Turn right + at Morning Roll Ave S". --> + <CarUiTextView + android:id="@+id/description_text" + style="?templateRoutingDescriptionStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateNavCardSmallPaddingVertical"/> + </LinearLayout> + + <!-- The image that displays the lanes (e.g. a series of arrows laid + horizontally. --> + <FrameLayout + android:id="@+id/lanes_image_container" + android:background="?templateRoutingLanesImageBackgroundColor" + android:layout_width="match_parent" + android:layout_height="?templateRoutingLanesImageContainerHeight" + android:paddingVertical="?templateRoutingLanesImageContainerVerticalPadding" + android:paddingHorizontal="?templateRoutingLanesImageContainerHorizontalPadding"> + <ImageView + android:id="@+id/lanes_image" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + android:clickable="false" + android:focusable="false" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + tools:ignore="ContentDescription" /> + </FrameLayout> + +</com.android.car.libraries.templates.host.view.widgets.navigation.DetailedStepView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/message_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/message_view.xml new file mode 100644 index 0000000..eaf1701 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/message_view.xml @@ -0,0 +1,62 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.navigation.MessageView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:paddingHorizontal="?templateNavCardPaddingHorizontal" + android:paddingVertical="?templateNavCardPaddingVertical"> + + <!-- An image showing the destination image. --> + <com.android.car.libraries.templates.host.view.widgets.common.CarImageView + android:id="@+id/message_image" + android:layout_width="?templateNavCardLargeImageSize" + android:layout_height="?templateNavCardLargeImageSize" + app:imageMinWidth="?templateNavCardLargeImageSizeMin" + app:imageMaxWidth="?templateNavCardLargeImageSizeMax" + app:imageMinHeight="?templateNavCardLargeImageSizeMin" + app:imageMaxHeight="?templateNavCardLargeImageSizeMax" + android:layout_gravity="start" + android:scaleType="fitCenter" + android:adjustViewBounds="true" + android:visibility="gone" + tools:ignore="ContentDescription" /> + + <!-- A title on top of the card, e.g. the name of the location the + user arrived at. --> + <CarUiTextView + android:id="@+id/message_title" + style="?templateRoutingMessagePrimaryStyle" + android:layout_gravity="start" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateRoutingMessageInnerPaddingVertical" /> + + <!-- A text view displaying the address of the location. --> + <CarUiTextView + android:id="@+id/message_text" + style="?templateRoutingMessageSecondaryStyle" + android:layout_gravity="start" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="?templateRoutingMessageInnerPaddingVertical" /> +</com.android.car.libraries.templates.host.view.widgets.navigation.MessageView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/progress_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/progress_view.xml new file mode 100644 index 0000000..1aa46e2 --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/progress_view.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.navigation.ProgressView + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="?templateNavCardPaddingHorizontal" + android:layout_marginVertical="?templateNavCardPaddingVertical" + android:layout_gravity="center" + android:orientation="vertical" + android:visibility="gone"> + <com.android.car.libraries.templates.host.view.widgets.common.CarProgressBar + android:id="@+id/progress_indicator" + style="?templateLoadingSpinnerStyle" + android:layout_width="?templateNavCardLargeImageSize" + android:layout_height="?templateNavCardLargeImageSize" + app:imageMinSize="?templateNavCardLargeImageSizeMin" + app:imageMaxSize="?templateNavCardLargeImageSizeMax" + android:layout_gravity="center_horizontal" /> + +</com.android.car.libraries.templates.host.view.widgets.navigation.ProgressView> diff --git a/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/travel_estimate_view.xml b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/travel_estimate_view.xml new file mode 100644 index 0000000..88ae5de --- /dev/null +++ b/Host/app/renderer/src/main/java/com/android/car/libraries/templates/host/view/widgets/navigation/res/layout/travel_estimate_view.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.libraries.templates.host.view.widgets.navigation.TravelEstimateView + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:gravity="start|center_vertical"> + + <!-- The first row showing the arrival time. --> + <CarUiTextView + android:id="@+id/arrival_time_text" + style="?templateRoutingTravelEstimateStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + + <!-- The first row showing the remaining time and distance. --> + <CarUiTextView + android:id="@+id/time_and_distance_text" + style="?templateRoutingTravelEstimateStyle" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> +</com.android.car.libraries.templates.host.view.widgets.navigation.TravelEstimateView> diff --git a/Host/app/renderer/src/main/res/layout/fragment_blank.xml b/Host/app/renderer/src/main/res/layout/fragment_blank.xml new file mode 100644 index 0000000..6b2a64d --- /dev/null +++ b/Host/app/renderer/src/main/res/layout/fragment_blank.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".BlankFragment"> + + <!-- TODO: Update blank fragment layout --> + <TextView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:text="@string/hello_blank_fragment" /> + +</FrameLayout> diff --git a/Host/app/renderer/src/main/res/values/strings.xml b/Host/app/renderer/src/main/res/values/strings.xml new file mode 100644 index 0000000..1c9056a --- /dev/null +++ b/Host/app/renderer/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ +<resources> + <!-- TODO: Remove or change this placeholder text --> + <string name="hello_blank_fragment">Hello blank fragment</string> +</resources> diff --git a/Host/app/src/main/AndroidManifest.xml b/Host/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c04a1b8 --- /dev/null +++ b/Host/app/src/main/AndroidManifest.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + package="com.android.car.templates.host" + android:versionCode="1" + android:versionName="0.1"> + + <uses-permission android:name="android.car.permission.TEMPLATE_RENDERER" /> + + <!-- Required to start foreground services --> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> + + <!-- Needed to start the host on boot completed --> + <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> + + <!-- Required for Cluster support --> + <uses-permission android:name="android.car.permission.CAR_DISPLAY_IN_CLUSTER" /> + <uses-permission android:name="android.car.permission.CAR_NAVIGATION_MANAGER" /> + + <queries> + <provider android:authorities="androidx.car.app.connection" + tools:replace="android:authorities"/> + <provider + android:name="com.android.car.ui.plugin.PluginNameProvider" + android:authorities="com.android.car.ui.plugin" + tools:ignore="ExportedContentProvider,MissingClass" + tools:replace="android:authorities"/> + </queries> + + + + <application + android:label="@string/app_name" + android:name="com.android.car.templates.host.TemplatesHostApplication" + android:icon="@drawable/ic_android"> + + <service android:name="com.android.car.libraries.templates.host.RendererService" + android:exported="true" + android:label="RendererService" + android:enabled="true" + android:foregroundServiceType="location" + tools:ignore="ExportedService"> + <intent-filter> + <action android:name="android.car.template.host.RendererService" /> + </intent-filter> + </service> + + <receiver android:name="com.android.car.libraries.templates.host.BootCompleteReceiver" + android:exported="false" + android:directBootAware="true"> + <intent-filter> + <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" /> + <action android:name="android.intent.action.BOOT_COMPLETED" /> + <action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> + </intent-filter> + </receiver> + </application> +</manifest> diff --git a/Host/app/src/main/java/com/android/car/templates/host/BlankFragment.kt b/Host/app/src/main/java/com/android/car/templates/host/BlankFragment.kt new file mode 100644 index 0000000..1808fec --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/BlankFragment.kt @@ -0,0 +1,59 @@ +package com.android.car.templates.host + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup + +// TODO: Rename parameter arguments, choose names that match +// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER +private const val ARG_PARAM1 = "param1" +private const val ARG_PARAM2 = "param2" + +/** + * A simple [Fragment] subclass. + * Use the [BlankFragment.newInstance] factory method to + * create an instance of this fragment. + */ +class BlankFragment : Fragment() { + // TODO: Rename and change types of parameters + private var param1: String? = null + private var param2: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + param1 = it.getString(ARG_PARAM1) + param2 = it.getString(ARG_PARAM2) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + // Inflate the layout for this fragment + return inflater.inflate(R.layout.fragment_blank, container, false) + } + + companion object { + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * + * @param param1 Parameter 1. + * @param param2 Parameter 2. + * @return A new instance of fragment BlankFragment. + */ + // TODO: Rename and change types and number of parameters + @JvmStatic + fun newInstance(param1: String, param2: String) = + BlankFragment().apply { + arguments = Bundle().apply { + putString(ARG_PARAM1, param1) + putString(ARG_PARAM2, param2) + } + } + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/TemplatesHostApplication.java b/Host/app/src/main/java/com/android/car/templates/host/TemplatesHostApplication.java new file mode 100644 index 0000000..debca84 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/TemplatesHostApplication.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.templates.host; + +import android.app.Application; +import dagger.hilt.android.HiltAndroidApp; + +/** This application class mark the whole application as Hilt application */ +@HiltAndroidApp(Application.class) +public class TemplatesHostApplication extends Hilt_TemplatesHostApplication {} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/HostResourceIdsImpl.java b/Host/app/src/main/java/com/android/car/templates/host/di/HostResourceIdsImpl.java new file mode 100644 index 0000000..cb19697 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/HostResourceIdsImpl.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.templates.host.di; + +import androidx.annotation.NonNull; +import com.android.car.libraries.apphost.common.ApiIncompatibilityType; +import com.android.car.libraries.apphost.common.HostResourceIds; +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.components.ServiceComponent; +import com.android.car.templates.host.R; + +/** The service level module to provide resources to AOSP Templates Host */ +@Module +@InstallIn(ServiceComponent.class) +public class HostResourceIdsImpl implements HostResourceIds { + + @Provides + static HostResourceIds provideHostResourceIds() { + return new HostResourceIdsImpl(); + } + + private HostResourceIdsImpl() {} + + @Override + public int getAlertIconDrawable() { + return com.android.car.templates.host.R.drawable.default_alert_icon; + } + + @Override + public int getErrorIconDrawable() { + return com.android.car.templates.host.R.drawable.default_error_icon; + } + + @Override + public int getBackIconDrawable() { + return com.android.car.templates.host.R.drawable.default_back_icon; + } + + @Override + public int getPanIconDrawable() { + return com.android.car.templates.host.R.drawable.default_ic_pan_button; + } + + @Override + public int getRefreshIconDrawable() { + return com.android.car.templates.host.R.drawable.default_ic_refresh_button; + } + + @Override + public int getRedColor() { + return R.color.default_standard_red; + } + + @Override + public int getRedDarkColor() { + return R.color.default_standard_red_dark; + } + + @Override + public int getGreenColor() { + return R.color.default_standard_green; + } + + @Override + public int getGreenDarkColor() { + return R.color.default_standard_green_dark; + } + + @Override + public int getBlueColor() { + return R.color.default_standard_blue; + } + + @Override + public int getBlueDarkColor() { + return R.color.default_standard_blue_dark; + } + + @Override + public int getYellowColor() { + return R.color.default_standard_yellow; + } + + @Override + public int getYellowDarkColor() { + return R.color.default_standard_yellow_dark; + } + + @Override + public int getDefaultPrimaryColor() { + return R.color.default_primary_color; + } + + @Override + public int getDefaultPrimaryDarkColor() { + return R.color.default_primary_dark_color; + } + + @Override + public int getDefaultSecondaryColor() { + return R.color.default_secondary_color; + } + + @Override + public int getDefaultSecondaryDarkColor() { + return R.color.default_secondary_dark_color; + } + + @Override + public int getDistanceInMetersStringFormat() { + return R.string.meter_text; + } + + @Override + public int getDistanceInKilometersStringFormat() { + return R.string.kilometer_text; + } + + @Override + public int getDistanceInFeetStringFormat() { + return R.string.feet_text; + } + + @Override + public int getDistanceInMilesStringFormat() { + return R.string.mile_text; + } + + @Override + public int getDistanceInYardsStringFormat() { + return R.string.yard_text; + } + + @Override + public int getTimeAtDestinationWithTimeZoneStringFormat() { + return R.string.time_at_destination_with_time_zone; + } + + @Override + public int getDurationInDaysStringFormat() { + return R.string.duration_in_days; + } + + @Override + public int getDurationInDaysAndHoursStringFormat() { + return R.string.duration_in_days_hours; + } + + @Override + public int getDurationInHoursStringFormat() { + return R.string.duration_in_hours; + } + + @Override + public int getDurationInHoursAndMinutesStringFormat() { + return R.string.duration_in_hours_minutes; + } + + @Override + public int getDurationInMinutesStringFormat() { + return R.string.duration_in_minutes; + } + + @Override + public int getAnrMessage() { + return R.string.anr_message; + } + + @Override + public int getAnrWait() { + return R.string.anr_wait; + } + + @Override + public int getAnrWaiting() { + return R.string.anr_waiting; + } + + @Override + public int getAppApiIncompatibleText(@NonNull ApiIncompatibilityType apiIncompatibilityType) { + return apiIncompatibilityType == ApiIncompatibilityType.APP_TOO_OLD + ? R.string.app_api_too_old + : R.string.host_api_too_old; + } + + @Override + public int getClientErrorText() { + return R.string.client_error_text; + } + + @Override + public int getMissingPermissionText() { + return R.string.missing_permission_text; + } + + @Override + public int getExitText() { + return R.string.exit_text; + } + + @Override + public int getParkedOnlyActionText() { + return R.string.parked_only_action; + } + + @Override + public int getSearchHintText() { + return R.string.search_hint; + } + + @Override + public int getSearchHintDisabledText() { + return R.string.search_hint_disabled; + } + + @Override + public int getDrivingStateMessageText() { + return R.string.driving_state_message; + } + + @Override + public int getTemplateListNoItemsText() { + return R.string.template_list_no_items; + } + + @Override + public int getLongMessageTemplateDisabledActionText() { + return R.string.long_message_disabled_action_text; + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/MapViewContainerModule.java b/Host/app/src/main/java/com/android/car/templates/host/di/MapViewContainerModule.java new file mode 100644 index 0000000..35dda4e --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/MapViewContainerModule.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.templates.host.di; + +import com.android.car.libraries.templates.host.di.MapViewContainerFactory; +import com.android.car.templates.host.view.widgets.maps.MapViewStubContainer; +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.components.ServiceComponent; + +/** A service level module for map related providers */ +@Module +@InstallIn(ServiceComponent.class) +public final class MapViewContainerModule { + @Provides + static MapViewContainerFactory provideMapViewContainerFactory() { + return MapViewStubContainer::create; + } + + private MapViewContainerModule() {} +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/TelemetryHandlerModule.java b/Host/app/src/main/java/com/android/car/templates/host/di/TelemetryHandlerModule.java new file mode 100644 index 0000000..ae0e23a --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/TelemetryHandlerModule.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.templates.host.di; + +import com.android.car.libraries.templates.host.di.TelemetryHandlerFactory; +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.components.ServiceComponent; + +/** A service level module for telemetry related providers */ +@Module +@InstallIn(ServiceComponent.class) +final class TelemetryHandlerModule { + @Provides + static TelemetryHandlerFactory provideTelemetryHandlerFactory() { + return TelemetryHandlerStub::create; + } + + private TelemetryHandlerModule() {} +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/TelemetryHandlerStub.java b/Host/app/src/main/java/com/android/car/templates/host/di/TelemetryHandlerStub.java new file mode 100644 index 0000000..8b57a5b --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/TelemetryHandlerStub.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.templates.host.di; + +import android.content.ComponentName; +import android.content.Context; +import android.util.Log; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.libraries.apphost.logging.TelemetryEvent; +import com.android.car.libraries.apphost.logging.TelemetryHandler; + +final class TelemetryHandlerStub extends TelemetryHandler { + + /** Returns a new instance of {@link TelemetryHandlerStub}. */ + public static TelemetryHandler create(Context context, ComponentName componentName) { + return new TelemetryHandlerStub(); + } + + @Override + public void logCarAppTelemetry(TelemetryEvent.Builder logEventBuilder) { + TelemetryEvent event = logEventBuilder.build(); + Log.d( + LogTags.APP_HOST, + "TelemetryHandlerStub log event for " + + event.getComponentName() + + " on " + + event.getAction().name()); + } + + private TelemetryHandlerStub() {} +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/ThemeManagerImpl.java b/Host/app/src/main/java/com/android/car/templates/host/di/ThemeManagerImpl.java new file mode 100644 index 0000000..cf6f3bd --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/ThemeManagerImpl.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.templates.host.di; + +import android.content.Context; +import com.android.car.libraries.templates.host.R; +import com.android.car.libraries.templates.host.di.ThemeManager; +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.components.ServiceComponent; + +/** The service level module to provide {@link ThemeManager}. */ +@Module +@InstallIn(ServiceComponent.class) +class ThemeManagerImpl implements ThemeManager { + + @Provides + static ThemeManager provideThemeManager() { + return new ThemeManagerImpl(); + } + + private ThemeManagerImpl() {} + + /** Applies appropriate theme to the given context. */ + @Override + public void applyTheme(Context context) { + context.getTheme().applyStyle(R.style.Theme_Template, true); + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/config/AndroidManifest.xml b/Host/app/src/main/java/com/android/car/templates/host/di/config/AndroidManifest.xml new file mode 100644 index 0000000..ee63c19 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/config/AndroidManifest.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.car.templates.host.di.config"> + + <uses-sdk android:minSdkVersion="29" /> +</manifest> diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/config/FeaturesConfigStub.java b/Host/app/src/main/java/com/android/car/templates/host/di/config/FeaturesConfigStub.java new file mode 100644 index 0000000..37d252c --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/config/FeaturesConfigStub.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.templates.host.di.config; + +import com.android.car.libraries.templates.host.di.FeaturesConfig; + +/** Stub implementation of {@link FeaturesConfig} that just works as a pass-through */ +final class FeaturesConfigStub implements FeaturesConfig { + @Override + public boolean isClusterActivityEnabled() { + return false; + } + + @Override + public boolean isNavPanZoomEnabled() { + return false; + } + + @Override + public boolean isPoiRoutePreviewPanZoomEnabled() { + return false; + } + + @Override + public boolean isPoiContentRefreshEnabled() { + return false; + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/config/HostApiLevelConfigStub.java b/Host/app/src/main/java/com/android/car/templates/host/di/config/HostApiLevelConfigStub.java new file mode 100644 index 0000000..c5b7b01 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/config/HostApiLevelConfigStub.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.templates.host.di.config; + +import android.content.ComponentName; +import com.android.car.libraries.templates.host.di.HostApiLevelConfig; + +/** Stub implementation of {@link HostApiLevelConfig} that just works as a pass-through */ +final class HostApiLevelConfigStub implements HostApiLevelConfig { + + @Override + public int getHostMinApiLevel(int defaultValue, ComponentName componentName) { + return defaultValue; + } + + @Override + public int getHostMaxApiLevel(int defaultValue, ComponentName componentName) { + return defaultValue; + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/config/StubModule.java b/Host/app/src/main/java/com/android/car/templates/host/di/config/StubModule.java new file mode 100644 index 0000000..ec671b5 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/config/StubModule.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.templates.host.di.config; + +import com.android.car.libraries.templates.host.di.FeaturesConfig; +import com.android.car.libraries.templates.host.di.HostApiLevelConfig; +import com.android.car.libraries.templates.host.di.UxreConfig; +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.components.ServiceComponent; + +/** The service level module to privde configs to AOSP Templates Host */ +@Module +@InstallIn(ServiceComponent.class) +class StubModule { + @Provides + static FeaturesConfig provideFeaturesConfig() { + return new FeaturesConfigStub(); + } + + @Provides + static UxreConfig provideUxreConfig() { + return new UxreConfigStub(); + } + + @Provides + static HostApiLevelConfig provideHostApiLevelConfig() { + return new HostApiLevelConfigStub(); + } + + private StubModule() {} +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/config/UxreConfigStub.java b/Host/app/src/main/java/com/android/car/templates/host/di/config/UxreConfigStub.java new file mode 100644 index 0000000..600a11f --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/config/UxreConfigStub.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.templates.host.di.config; + +import com.android.car.libraries.templates.host.di.UxreConfig; + +/** Stub implementation of {@link UxreConfig} that just works as a pass-through */ +final class UxreConfigStub implements UxreConfig { + @Override + public int getTemplateStackMaxSize(int defaultValue) { + return defaultValue; + } + @Override + public int getRouteListMaxLength(int defaultValue) { + return defaultValue; + } + @Override + public int getPaneMaxLength(int defaultValue) { + return defaultValue; + } + @Override + public int getGridMaxLength(int defaultValue) { + return defaultValue; + } + @Override + public int getListMaxLength(int defaultValue) { + return defaultValue; + } + @Override + public int getCarAppDefaultMaxStringLength(int defaultValue) { + return defaultValue; + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/di/res/values/strings.xml b/Host/app/src/main/java/com/android/car/templates/host/di/res/values/strings.xml new file mode 100644 index 0000000..50e23eb --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/di/res/values/strings.xml @@ -0,0 +1,117 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> + <!-- SearchTemplate Hint [CHAR_LIMIT=20] --> + <string name="search_hint">Search</string> + + <!-- SearchTemplate Hint during the disabled state [CHAR_LIMIT=30] --> + <string name="search_hint_disabled">Not while driving</string> + + <!-- VoiceTemplate state description for listening [CHAR_LIMIT=20] --> + <string name="state_listening">Listening</string> + + <!-- VoiceTemplate state description for speaking [CHAR_LIMIT=20] --> + <string name="state_speaking">Speaking</string> + + <!-- VoiceTemplate state description for mic muted [CHAR_LIMIT=50] --> + <string name="state_muted">Muted</string> + + <!-- VoiceTemplate content description for microphone image [CHAR_LIMIT=20] --> + <string name="mic_icon" translatable="false">Microphone icon</string> + + <!-- Text for meters abbreviation [CHAR_LIMIT=10] --> + <string name="meter_text">%s m</string> + + <!-- Text for kilometers abbreviation [CHAR_LIMIT=10] --> + <string name="kilometer_text">%s km</string> + + <!-- Text for feet abbreviation [CHAR_LIMIT=10] --> + <string name="feet_text">%s ft</string> + + <!-- Text for miles abbreviation [CHAR_LIMIT=10] --> + <string name="mile_text">%s mi</string> + + <!-- Text for yards abbreviation [CHAR_LIMIT=10] --> + <string name="yard_text">%s yd</string> + + <!-- Message when lists have no items [CHAR_LIMIT=20] --> + <string name="template_list_no_items">No items</string> + + + <!-- A message shown while navigating to indicate when the user will arrive at their + destination. This combines a time of arrival with the short abbreviation of the + time zone it is in. Keep this short, as this is always present on-screen during + navigation. [CHAR_LIMIT=10] --> + <string name="time_at_destination_with_time_zone"> + <xliff:g id="time_at_destination_with_time_zone" example="5:18 PM">%1$s</xliff:g>\u00A0<xliff:g id="short_time_zone" example="PST">%2$s</xliff:g> + </string> + + <!-- The duration string that shows days only (e.g. "2 d"). [CHAR_LIMIT=10] --> + <string name="duration_in_days"><xliff:g example="2" id="days_count">%1$d</xliff:g> d</string> + + <!-- The duration string that shows days and hours (e.g. "2 d 15 hr"). [CHAR_LIMIT=10] --> + <string name="duration_in_days_hours"><xliff:g example="2" id="days_count">%1$d</xliff:g> d <xliff:g example="15" id="hours_count">%2$d</xliff:g> hr</string> + + <!-- The duration string that shows hours only (e.g. "23 hr"). [CHAR_LIMIT=10] --> + <string name="duration_in_hours"><xliff:g example="23" id="hours_count">%1$d</xliff:g> hr</string> + + <!-- The duration string that shows hours and minutes (e.g. "23 hr 15 min"). [CHAR_LIMIT=10] --> + <string name="duration_in_hours_minutes"><xliff:g example="23" id="hours_count">%1$d</xliff:g> hr <xliff:g example="15" id="minutes_count">%2$d</xliff:g> min</string> + + <!-- The duration string that shows minutes only (e.g. "15 min"). [CHAR_LIMIT=10] --> + <string name="duration_in_minutes"><xliff:g example="15" id="minutes_count">%1$d</xliff:g> min</string> + + <!-- Toast message for user selecting action that can only be selected when parked [CHAR_LIMIT=40] --> + <string name="parked_only_action">Not available when driving</string> + + <!-- Error message for client app exception [CHAR_LIMIT=70] --> + <string name="client_error_text"><xliff:g name="app">%s</xliff:g> has encountered an unexpected error</string> + + <!-- Error message for client app exception [CHAR_LIMIT=10] --> + <string name="exit_text">Exit</string> + + <!-- Error message for app requiring an older host than supported [CHAR_LIMIT=80] --> + <string name="app_api_too_old">This version of <xliff:g name="app">%s</xliff:g> is not supported. Please update.</string> + + <!-- Error message for app requiring a newer host than available [CHAR_LIMIT=80] --> + <string name="host_api_too_old"><xliff:g name="app">%s</xliff:g> requires a newer version of Android Automotive Templates Host.</string> + + <!-- Error message for application not responding [CHAR_LIMIT=100] --> + <string name="anr_message"><xliff:g name="app">%s</xliff:g> isn\'t responding</string> + + <!-- Button text for waiting for ANR [CHAR_LIMIT=15] --> + <string name="anr_wait">Wait</string> + + <!-- Error message for waiting for application to respond [CHAR_LIMIT=20] --> + <string name="anr_waiting">Waiting…</string> + + <!-- Error message for the application not having required permission [CHAR_LIMIT=100] --> + <string name="missing_permission_text"><xliff:g name="app">%s</xliff:g> needs a required permission</string> + + <!-- CarUxRestrictions Utility --> + <string name="car_app_ellipsis" translatable="false">…</string> + + <!-- Message to indicate that the vehicle must be parked to continue using the app. [CHAR_LIMIT=50] --> + <string name="driving_state_message">You can\'t use this feature while driving</string> + + <!-- Text for the rerouting state [CHAR_LIMIT=20] --> + <string name="rerouting_text">Rerouting</string> + + <!-- Toast message for disabled action button in long message template. [CHAR_LIMIT=40] --> + <string name="long_message_disabled_action_text">Scroll to continue</string> +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_action_button_background.xml b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_action_button_background.xml new file mode 100644 index 0000000..4aaee56 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_action_button_background.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- The background fill --> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/default_action_button_background_color_selector"/> + <corners + android:radius="@dimen/car_app_ui_button_corner_radius"/> + </shape> + </item> + + <!-- Masked ripple layer --> + <item android:drawable="@drawable/default_action_button_ripple"/> +</layer-list> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_action_button_ripple.xml b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_action_button_ripple.xml new file mode 100644 index 0000000..ba31afc --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_action_button_ripple.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/default_ripple_color_selector"> + <!-- Ripple layer masked so that it is only drawn within the bounds of the view --> + <item + android:id="@android:id/mask" + android:gravity="center"> + <shape android:shape="rectangle"> + <solid android:color="@color/default_ripple_color_selector"/> + <corners android:radius="@dimen/car_app_ui_button_corner_radius"/> + </shape> + </item> +</ripple> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_edit_text_background.xml b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_edit_text_background.xml new file mode 100644 index 0000000..e6b3670 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_edit_text_background.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <shape android:shape="rectangle"> + <solid android:color="@color/default_edit_text_background_color_selector"/> + <corners android:radius="@dimen/car_app_ui_corner_radius"/> + </shape> + </item> +</layer-list> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_edit_text_foreground.xml b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_edit_text_foreground.xml new file mode 100644 index 0000000..4451f8d --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_edit_text_foreground.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:gravity="bottom"> + <shape> + <size android:height="@dimen/car_app_ui_edit_text_border_width" /> + <solid android:color="@color/default_edit_text_foreground_color_selector" /> + </shape> + </item> +</layer-list> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_ic_pan_button.xml b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_ic_pan_button.xml new file mode 100644 index 0000000..171d5c2 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_ic_pan_button.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M15.54,5.54L13.77,7.3 12,5.54 10.23,7.3 8.46,5.54 12,2zM18.46,15.54l-1.76,-1.77L18.46,12l-1.76,-1.77 1.76,-1.77L22,12zM8.46,18.46l1.77,-1.76L12,18.46l1.77,-1.76 1.77,1.76L12,22zM5.54,8.46l1.76,1.77L5.54,12l1.76,1.77 -1.76,1.77L2,12z"/> + <path + android:fillColor="@android:color/white" + android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"/> +</vector> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_ic_refresh_button.xml b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_ic_refresh_button.xml new file mode 100644 index 0000000..6384396 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/default_ic_refresh_button.xml @@ -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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M12,20q-3.35,0 -5.675,-2.325Q4,15.35 4,12q0,-3.35 2.325,-5.675Q8.65,4 12,4q1.725,0 3.3,0.713 1.575,0.712 2.7,2.037V4h2v7h-7V9h4.2q-0.8,-1.4 -2.188,-2.2Q13.625,6 12,6 9.5,6 7.75,7.75T6,12q0,2.5 1.75,4.25T12,18q1.925,0 3.475,-1.1T17.65,14h2.1q-0.7,2.65 -2.85,4.325Q14.75,20 12,20z"/> +</vector> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/drawable/ic_android.xml b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/ic_android.xml new file mode 100644 index 0000000..db80c86 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/drawable/ic_android.xml @@ -0,0 +1,74 @@ +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<vector android:height="48dp" android:viewportHeight="192" + android:viewportWidth="192" android:width="48dp" + xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillAlpha="0" android:fillColor="#FF000000" + android:pathData="M0,0h192v192H0z" android:strokeAlpha="0"/> + <path android:pathData="M110.2,111.22l49.8,49.8V59.39l-49.8,49.8z"> + <aapt:attr name="android:fillColor"> + <gradient android:endX="45.221" android:endY="110.207" + android:startX="163.779" android:startY="110.207" android:type="linear"> + <item android:color="#FFFFE000" android:offset="0"/> + <item android:color="#FFFFBD00" android:offset="0.409"/> + <item android:color="#FFFFA500" android:offset="0.775"/> + <item android:color="#FFFF9C00" android:offset="1"/> + </gradient> + </aapt:attr> + </path> + <path android:pathData="M110.2,109.19L49,48h-7.05c-5.5,0 -9.95,4.22 -9.95,9.72V92.9c0,3.55 2.88,6.43 6.43,6.43 1.56,0 2.99,-0.56 4.1,-1.48l1.05,-0.83c3.01,-2.64 6.96,-4.23 11.28,-4.23 9.46,0 17.14,7.73 17.14,17.2 0,9.47 -7.68,17.08 -17.14,17.08 -4.32,0 -8.27,-1.59 -11.28,-4.23l-1.05,-0.83c-1.11,-0.92 -2.54,-1.48 -4.1,-1.48 -3.55,0 -6.43,2.88 -6.43,6.43v34.76c0,5.5 4.45,10.28 9.95,10.28h7.47l60.78,-60.78v-2.03z"> + <aapt:attr name="android:fillColor"> + <gradient android:endX="10.114" android:endY="143.447" + android:startX="105.972" android:startY="47.589" android:type="linear"> + <item android:color="#FF00A0FF" android:offset="0"/> + <item android:color="#FF00A1FF" android:offset="0.007"/> + <item android:color="#FF00BEFF" android:offset="0.26"/> + <item android:color="#FF00D2FF" android:offset="0.512"/> + <item android:color="#FF00DFFF" android:offset="0.76"/> + <item android:color="#FF00E3FF" android:offset="1"/> + </gradient> + </aapt:attr> + </path> + <path android:pathData="M160,60.4v-2.68c0,-5.5 -4.45,-9.72 -9.95,-9.72h-37.02c-3.55,0 -6.43,-2.77 -6.43,-6.32 0,-1.56 0.56,-3.1 1.48,-4.21l0.83,-1.05c2.64,-3.01 4.23,-6.96 4.23,-11.28C113.14,15.68 105.47,8 96,8s-17.14,7.69 -17.14,17.15c0,4.32 1.59,8.28 4.23,11.29l0.83,1.08c0.92,1.11 1.48,2.6 1.48,4.16 0,3.55 -2.88,6.32 -6.43,6.32H47.99l62.21,62.21L160,60.4z"> + <aapt:attr name="android:fillColor"> + <gradient android:endX="93.393" android:endY="40.253" + android:startX="35.008" android:startY="-18.132" android:type="linear"> + <item android:color="#FF32A071" android:offset="0"/> + <item android:color="#FF2DA771" android:offset="0.069"/> + <item android:color="#FF15CF74" android:offset="0.476"/> + <item android:color="#FF06E775" android:offset="0.801"/> + <item android:color="#FF00F076" android:offset="1"/> + </gradient> + </aapt:attr> + </path> + <path android:pathData="M48.4,172h101.65c5.5,0 9.95,-4.78 9.95,-10.28v-1.71l-49.8,-49.81L48.4,172z"> + <aapt:attr name="android:fillColor"> + <gradient android:endX="4.794" android:endY="268.414" + android:startX="135.545" android:startY="137.663" android:type="linear"> + <item android:color="#FFFF3A44" android:offset="0"/> + <item android:color="#FFC31162" android:offset="1"/> + </gradient> + </aapt:attr> + </path> + <path android:fillAlpha="0.25" android:fillColor="#FFFFFF" + android:pathData="M96,9c9.31,0 16.86,7.43 17.12,16.66 0.01,-0.18 0.02,-0.35 0.02,-0.52C113.14,15.68 105.47,8 96,8s-17.14,7.69 -17.14,17.15c0,0.18 0.02,0.35 0.02,0.52C79.14,16.44 86.69,9 96,9zM41.95,49h37.02c3.55,0 6.43,-2.77 6.43,-6.32 0,-0.12 -0.03,-0.24 -0.03,-0.36 -0.32,3.24 -3.07,5.68 -6.4,5.68H41.95c-5.5,0 -9.95,4.22 -9.95,9.72v1c0,-5.5 4.45,-9.72 9.95,-9.72zM38.43,121.53c1.56,0 2.99,0.56 4.1,1.48l1.05,0.83c3.01,2.64 6.96,4.23 11.28,4.23 9.46,0 17.14,-7.61 17.14,-17.08 0,-0.17 -0.02,-0.33 -0.03,-0.5 -0.27,9.23 -7.82,16.58 -17.11,16.58 -4.32,0 -8.27,-1.59 -11.28,-4.23l-1.05,-0.83c-1.11,-0.92 -2.54,-1.48 -4.1,-1.48 -3.55,0 -6.43,2.88 -6.43,6.43v1c0,-3.55 2.88,-6.43 6.43,-6.43zM150.05,48h-37.02c-3.34,0 -6.08,-2.45 -6.4,-5.69 -0.01,0.12 -0.03,0.24 -0.03,0.37 0,3.55 2.88,6.32 6.43,6.32h37.02c5.5,0 9.95,4.22 9.95,9.72v-1c0,-5.5 -4.45,-9.72 -9.95,-9.72z" android:strokeAlpha="0.25"/> + <path android:fillAlpha="0.12" android:fillColor="#231F20" + android:pathData="M83.92,37.52c0.85,1.02 1.37,2.37 1.45,3.8 0.02,-0.21 0.03,-0.42 0.03,-0.64 0,-1.56 -0.56,-3.05 -1.48,-4.16l-0.83,-1.08c-2.53,-2.89 -4.08,-6.65 -4.21,-10.77 0,0.16 -0.02,0.31 -0.02,0.48 0,4.32 1.59,8.28 4.23,11.29l0.83,1.08zM108.08,36.47c-0.92,1.11 -1.48,2.65 -1.48,4.21 0,0.21 0.01,0.43 0.03,0.63 0.08,-1.43 0.6,-2.82 1.45,-3.84l0.83,-1.05c2.64,-3.01 4.23,-6.96 4.23,-11.28 0,-0.16 -0.02,-0.32 -0.02,-0.48 -0.12,4.11 -1.67,7.87 -4.21,10.76l-0.83,1.05zM38.43,99.33c1.56,0 2.99,-0.56 4.1,-1.48l1.05,-0.83c3.01,-2.64 6.96,-4.23 11.28,-4.23 9.29,0 16.85,7.46 17.11,16.7 0,-0.17 0.03,-0.33 0.03,-0.5 0,-9.47 -7.68,-17.2 -17.14,-17.2 -4.32,0 -8.27,1.59 -11.28,4.23l-1.05,0.83c-1.11,0.92 -2.54,1.48 -4.1,1.48 -3.55,0 -6.43,-2.88 -6.43,-6.43v1c0,3.55 2.88,6.43 6.43,6.43z" android:strokeAlpha="0.12"/> + <path android:fillAlpha="0.2" android:fillColor="#231F20" + android:pathData="M150.05,171H49.4l-1,1h101.65c5.5,0 9.95,-4.78 9.95,-10.28v-1c0,5.5 -4.45,10.28 -9.95,10.28z" android:strokeAlpha="0.2"/> + <path android:fillAlpha="0.12" android:fillColor="#231F20" + android:pathData="M41.95,171c-5.5,0 -9.95,-4.78 -9.95,-10.28v1c0,5.5 4.45,10.28 9.95,10.28h6.45l1,-1h-7.45z" android:strokeAlpha="0.12"/> +</vector> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/layout/fragment_blank.xml b/Host/app/src/main/java/com/android/car/templates/host/res/layout/fragment_blank.xml new file mode 100644 index 0000000..6b2a64d --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/layout/fragment_blank.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".BlankFragment"> + + <!-- TODO: Update blank fragment layout --> + <TextView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:text="@string/hello_blank_fragment" /> + +</FrameLayout> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values-night/colors.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values-night/colors.xml new file mode 100644 index 0000000..24bad14 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values-night/colors.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + + <color name="default_hun_text_color">#E0FFFFFF</color> + <color name="default_hun_text_color2">#80FFFFFF</color> + + <color name="default_card_text_color">#CCFFFFFF</color> + <color name="default_card_background_color">@color/default_gray_868</color> + <color name="default_focus_blue">#2371CD</color> + +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values-night/colors_overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values-night/colors_overlayable.xml new file mode 100644 index 0000000..f7a1c51 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values-night/colors_overlayable.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + + <!-- Buttons. --> + <color name="car_app_ui_floating_button_default_background_color">@color/default_white</color> + <color name="car_app_ui_floating_button_default_text_color">@color/default_black</color> + +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/attrs.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/attrs.xml new file mode 100644 index 0000000..07b3c19 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/attrs.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- Custom error state to be used in edit boxes or other components that support this state --> +<!-- <declare-styleable name="ErrorState">--> +<!-- <attr name="state_error" format="boolean"/>--> +<!-- </declare-styleable>--> + + <!-- Custom button type to be used in action buttons or other component that support this + classification --> + <declare-styleable name="ButtonType"> + <!-- Indicates this a "primary" button, out of a set of other buttons --> + <attr name="type_primary" format="boolean"/> + <!-- Indicates this an app button, background color controlled by the app --> + <attr name="type_custom" format="boolean"/> + </declare-styleable> +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/bools_overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/bools_overlayable.xml new file mode 100644 index 0000000..22e64fe --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/bools_overlayable.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Boolean definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these booleans directly from views. Booleans must be + referred to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <bool name="car_app_ui_customized">false</bool> + <bool name="car_app_ui_is_action_color_overridden">false</bool> + <bool name="car_app_ui_action_button_list_button_stretch_horizontal">false</bool> +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/colors.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/colors.xml new file mode 100644 index 0000000..0ca7b10 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/colors.xml @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- Colors used as the default value for overlayable resources. + These resources are not directly overlayable. --> + <color name="default_white">#FFFFFF</color> + <color name="default_gradient_white_12">#1FFFFFFF</color> + <color name="default_gradient_white_16">#29FFFFFF</color> + <color name="default_gradient_white_24">#3DFFFFFF</color> + <color name="default_gradient_white_40">#66FFFFFF</color> + <color name="default_gradient_white_46">#75FFFFFF</color> + <color name="default_gradient_white_56">#8FFFFFFF</color> + <color name="default_gradient_white_72">#B8FFFFFF</color> + <color name="default_gray_50">#F8F9FA</color> + <color name="default_gray_100">#F1F3F4</color> + <color name="default_gray_200">#E8EAED</color> + <color name="default_gray_300">#DADCE0</color> + <color name="default_gray_400">#BDC1C6</color> + <color name="default_gray_500">#9AA0A6</color> + <color name="default_gray_600">#80868B</color> + <color name="default_gray_700">#5F6368</color> + <color name="default_gray_800">#3C4043</color> + <color name="default_gray_846">#2E3134</color> + <color name="default_gray_868">#282A2D</color> + <color name="default_gray_878">#2A2A29</color> + <color name="default_gray_900">#202124</color> + <color name="default_gray_928">#17181B</color> + <color name="default_gray_958">#0E1013</color> + <color name="default_black">#000000</color> + <color name="default_gradient_black_0">#00000000</color> + <color name="default_gradient_black_25">#40000000</color> + <color name="default_gradient_black_64">#A3000000</color> + <color name="default_gradient_black_72">#B8000000</color> + <color name="default_gradient_black_85">#D9000000</color> + <color name="default_gradient_black_88">#E0000000</color> + <color name="default_gradient_black_100">#FF000000</color> + + <!-- Default colors. --> + <color name="default_text_color">@color/default_white</color> + + <!-- Standard colors --> + <color name="default_standard_red">#FFEE675C</color> + <color name="default_standard_red_dark">#FFC5221F</color> + <color name="default_standard_green">#FF61AC70</color> + <color name="default_standard_green_dark">#FF448B47</color> + <color name="default_standard_blue">#FF669DF6</color> + <color name="default_standard_blue_dark">#FF3674E0</color> + <color name="default_standard_yellow">#FFE9A240</color> + <color name="default_standard_yellow_dark">#FFD5792D</color> + + <!-- Default car app colors is customizable only with Car UI Library. --> + <color name="default_primary_color">@color/car_ui_text_color_primary</color> + <color name="default_primary_dark_color">@color/car_ui_text_color_primary</color> + <color name="default_secondary_color">@color/car_ui_text_color_secondary</color> + <color name="default_secondary_dark_color">@color/car_ui_text_color_secondary</color> + + <!-- LINT.IfChange --> + <color name="default_hun_text_color">@color/default_white</color> + <color name="default_hun_text_color2">#8FFFFFFF</color> + + <color name="default_card_text_color">@color/default_white</color> + <color name="default_background_color">@color/default_black</color> + <color name="default_card_background_color">@color/default_gray_846</color> + <color name="default_focus_blue">#4B9EFF</color> + <!-- LINT.ThenChange(../values-night/colors.xml) --> + + <color name="default_message_debug_text_color">#FF57F1B1</color> + + <color name="default_focus_no_content">#48FFFFFF</color> + <color name="default_controller_ripple_selector_color">#b27da9c7</color> + <color name="default_controller_ripple_color">#66ffffff</color> +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/colors_overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/colors_overlayable.xml new file mode 100644 index 0000000..e9c2531 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/colors_overlayable.xml @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Color definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these colors directly from views. Colors must be referred + to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. + --> + + <!-- Standard colores --> + <color name="car_app_ui_standard_red">@color/default_standard_red</color> + <color name="car_app_ui_standard_red_dark">@color/default_standard_red_dark</color> + <color name="car_app_ui_standard_green">@color/default_standard_green</color> + <color name="car_app_ui_standard_green_dark">@color/default_standard_green_dark</color> + <color name="car_app_ui_standard_blue">@color/default_standard_blue</color> + <color name="car_app_ui_standard_blue_dark">@color/default_standard_blue_dark</color> + <color name="car_app_ui_standard_yellow">@color/default_standard_yellow</color> + <color name="car_app_ui_standard_yellow_dark">@color/default_standard_yellow_dark</color> + + <!-- Button --> + <color name="car_app_ui_action_button_default_background_color">@color/default_gray_846</color> + <color name="car_app_ui_action_button_primary_background_color">@color/default_standard_blue</color> + <color name="car_app_ui_action_button_text_color">@color/default_white</color> + <color name="car_app_ui_floating_button_default_background_color">@color/default_black</color> + <color name="car_app_ui_floating_button_default_text_color">@color/default_white</color> + + <!-- Read-only Text --> + <color name="car_app_ui_read_only_text_color">@color/default_black</color> + <color name="car_app_ui_read_only_text_background_color">@color/default_white</color> + + <!-- Edit Text --> + <color name="car_app_ui_edit_text_active_color">@color/car_app_ui_standard_blue</color> + <color name="car_app_ui_edit_text_enabled_color">@color/default_gradient_white_72</color> + <color name="car_app_ui_edit_text_error_color">@color/car_app_ui_standard_red</color> + <color name="car_app_ui_edit_text_disabled_color">@color/default_gradient_white_56</color> + + <!-- Hyperlink Text --> + <color name="car_app_ui_hyperlink_text_color">@color/default_white</color> + + <!-- Rows --> + <color name="car_app_ui_row_background_color">@color/car_ui_activity_background_color</color> + + <!-- Grids --> + <color name="car_app_ui_grid_item_background_color">@color/car_ui_activity_background_color</color> + +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/dimens_overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/dimens_overlayable.xml new file mode 100644 index 0000000..dd56d1e --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/dimens_overlayable.xml @@ -0,0 +1,116 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Dimension definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these dimensions directly from views. Dimensions must be + referred to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <!-- Template element spacing --> + <dimen name="car_app_ui_image_to_text_spacing_vertical">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_text_to_control_spacing_vertical">@dimen/car_ui_padding_5</dimen> + <dimen name="car_app_ui_text_to_secondary_control_spacing_vertical">@dimen/car_ui_padding_7</dimen> + <dimen name="car_app_ui_control_to_text_spacing_vertical">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_control_to_control_spacing_horizontal">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_content_horizontal_margin">24dp</dimen> + <dimen name="car_app_ui_touch_target_size">@dimen/car_ui_touch_target_size</dimen> + + <!-- Template element corner radius --> + <dimen name="car_app_ui_corner_radius">8dp</dimen> + + <!-- Card spacing --> + <dimen name="car_app_ui_card_start_margin">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_card_top_margin">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_card_width">0dp</dimen> + + <!-- Template image sizing --> + <dimen name="car_app_ui_large_image_size">@dimen/car_ui_list_item_content_icon_width</dimen> + + <!-- Navigation card spacing --> + <dimen name="car_app_ui_nav_card_padding_vertical">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_nav_card_padding_horizontal">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_nav_card_image_to_text_spacing_horizontal">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_nav_card_large_text_size">32sp</dimen> + <dimen name="car_app_ui_nav_card_xlarge_text_size">44sp</dimen> + <dimen name="car_app_ui_nav_card_small_padding_vertical">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_nav_card_image_to_text_spacing_vertical">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_nav_card_width">0dp</dimen> + <dimen name="car_app_ui_nav_card_small_image_size">36dp</dimen> + <dimen name="car_app_ui_nav_card_large_image_size">64dp</dimen> + + <!-- Card header spacing/sizing --> + <dimen name="car_app_ui_card_header_image_size">44dp</dimen> + <dimen name="car_app_ui_card_header_text_padding_horizontal">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_card_header_text_padding_vertical">@dimen/car_ui_padding_2</dimen> + <dimen name="car_app_ui_card_header_no_button_text_margin_start">@dimen/car_ui_padding_4</dimen> + + <!-- Grid item spacing/sizing --> + <dimen name="car_app_ui_grid_item_vertical_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_grid_item_image_to_text_spacing_vertical">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_grid_item_text_to_text_spacing_vertical">@dimen/car_ui_padding_1</dimen> + + <!-- Button spacing/sizing --> + <dimen name="car_app_ui_button_height">56dp</dimen> + <dimen name="car_app_ui_button_image_size">36dp</dimen> + <dimen name="car_app_ui_icon_button_start_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_icon_button_end_spacing">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_icon_button_image_to_text_spacing">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_button_text_horizontal_spacing">@dimen/car_ui_padding_5</dimen> + <dimen name="car_app_ui_button_corner_radius">4dp</dimen> + <dimen name="car_app_ui_action_button_list_button_max_width">800dp</dimen> + <dimen name="car_app_ui_button_side_alignment_spacing">@dimen/car_ui_padding_4</dimen> + + <!-- Edit text spacing/sizing --> + <dimen name="car_app_ui_edit_text_top_padding">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_edit_text_bottom_padding">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_edit_text_start_padding">@dimen/car_ui_padding_2</dimen> + <dimen name="car_app_ui_edit_text_end_padding">@dimen/car_ui_padding_4</dimen> + <dimen name="car_app_ui_edit_text_error_vertical_spacing">@dimen/car_ui_padding_1</dimen> + <dimen name="car_app_ui_edit_text_error_horizontal_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_edit_text_border_width">2dp</dimen> + + <!-- Read-only Text. --> + <dimen name="car_app_ui_read_only_text_padding">@dimen/car_ui_padding_4</dimen> + + <!-- Compact row spacing/sizing. These rows are used inside cards. --> + <dimen name="car_app_ui_half_row_min_height">0dp</dimen> + <dimen name="car_app_ui_half_row_horizontal_padding">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_half_row_vertical_padding">@dimen/car_ui_padding_2</dimen> + <dimen name="car_app_ui_half_row_image_to_text_spacing">@dimen/car_ui_padding_3</dimen> + <dimen name="car_app_ui_half_row_text_to_text_spacing">@dimen/car_ui_padding_0</dimen> + <dimen name="car_app_ui_half_row_image_size">44dp</dimen> + + <!-- Full row spacing/sizing. --> + <dimen name="car_app_ui_full_row_start_padding">@dimen/car_ui_list_item_text_start_margin</dimen> + <dimen name="car_app_ui_full_row_end_padding">0dp</dimen> + + <!-- Sign-in template spacing/sizing. --> + <dimen name="car_app_ui_sign_in_method_max_width">640dp</dimen> + +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/drawable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/drawable.xml new file mode 100644 index 0000000..f363b45 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/drawable.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- Drawables used as the default value for overlayable resources. + These resources are not directly overlayable. --> + <drawable name="default_error_icon">@drawable/car_ui_icon_error</drawable> + <drawable name="default_alert_icon">@drawable/car_ui_icon_error</drawable> + <drawable name="default_back_icon">@drawable/car_ui_icon_arrow_back</drawable> +</resources>
\ No newline at end of file diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/drawable_overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/drawable_overlayable.xml new file mode 100644 index 0000000..042bbf4 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/drawable_overlayable.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Drawable definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these drawables directly from views. Drawables must be referred + to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <drawable name="car_app_ui_action_button_background">@drawable/default_action_button_background</drawable> +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/integers.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/integers.xml new file mode 100644 index 0000000..0a589d5 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/integers.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- Gravity integer values (to be used as part of gravity overlayable attributes. --> +<!-- <integer name="gravity_bottom">80</integer>--> +<!-- <integer name="gravity_center">17</integer>--> +<!-- <integer name="gravity_center_horizontal">1</integer>--> +<!-- <integer name="gravity_center_vertical">16</integer>--> +<!-- <integer name="gravity_end">8388613</integer>--> +<!-- <integer name="gravity_left">3</integer>--> +<!-- <integer name="gravity_no_gravity">0</integer>--> +<!-- <integer name="gravity_right">5</integer>--> +<!-- <integer name="gravity_start">8388611</integer>--> +<!-- <integer name="gravity_top">48</integer>--> +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/integers_overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/integers_overlayable.xml new file mode 100644 index 0000000..0123000 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/integers_overlayable.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Integer definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these integers directly from views. Integers must be + referred to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. + + !!! IMPORTANT !!! + These resources must be added to the overlayable.xml file. DO NOT + add documentation here. Concentrate all documentation in public-ready + comments in the overlayable.xml file. --> + + <integer name="car_app_ui_list_max_length">6</integer> + <integer name="car_app_ui_grid_max_length">6</integer> + <integer name="car_app_ui_action_button_primary_horizontal_order">0</integer> + <integer name="car_app_ui_action_button_list_gravity">0</integer> + <integer name="car_app_ui_action_button_list_button_content_alignment">0</integer> + <integer name="car_app_ui_content_layout_gravity">@integer/gravity_center</integer> + <integer name="car_app_ui_content_gravity">@integer/gravity_center</integer> +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/overlayable.xml new file mode 100644 index 0000000..289f8bc --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/overlayable.xml @@ -0,0 +1,306 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- List of the resource that can be customized by the OEMs by using + Runtime Resource Overlays. + + !!! IMPORTANT !!! + + Comments on this file are used to produce automatically generated + documentation available at https://docs.partner.android.com/gas/integrate/template_host. + + Once per AAOS Host release, the following tool should be used to re-generate the publicly + documented resource list. This list constitutes an API with the OEMs. DO NOT remove or + rename an existing resource without a corresponding deprecation cycle. + + third_party/java_src/android_libs/car/aaos_host/main/com/android/car/libraries/templates/host/overlayable/tools/generateDoc.py + --> + <overlayable name="OverlayableResources"> + <policy type="system|product|vendor|signature"> + + <!-- Indicates whether OEMs have done any UI customizations. This value should be set to true + by the OEMs who wish to provide UI customization. --> + <item type="bool" name="car_app_ui_customized" /> + <!-- Indicates whether OEMs choose to ignore app provided colors on + buttons on select templates. This value should be set to true by the + OEMs who wish to ignore app provided colors on buttons on select + templates. --> + <item type="bool" name="car_app_ui_is_action_color_overridden" /> + <!-- Indicates whether buttons in the action button list (e.g. used in PaneTemplate) + stretch to fill the horizontal space. --> + <item type="bool" name="car_app_ui_action_button_list_button_stretch_horizontal" /> + + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_red" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_red_dark" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_green" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_green_dark" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_blue" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_blue_dark" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_yellow" /> + <!-- Car App Library standard color. --> + <item type="color" name="car_app_ui_standard_yellow_dark" /> + <!-- Default background color used on 'Action' buttons when one is not provided by the + application. --> + <item type="color" name="car_app_ui_action_button_default_background_color" /> + <!-- Background color used on 'Action' buttons marked as 'Primary', when one is not provided + by the application. --> + <item type="color" name="car_app_ui_action_button_primary_background_color" /> + <!-- Text color used on 'Action' buttons when one is not provided by the application. --> + <item type="color" name="car_app_ui_action_button_text_color" /> + <!-- Background color used on FABs (floating action buttons) when one is not provided by the + application. --> + <item type="color" name="car_app_ui_floating_button_default_background_color" /> + <!-- Text color used on FABs (floating action buttons) when one is not provided by the + application. --> + <item type="color" name="car_app_ui_floating_button_default_text_color" /> + <!-- Text color used on read-only text boxes (such as the PIN code in Sign-In template). --> + <item type="color" name="car_app_ui_read_only_text_color" /> + <!-- Background color used on read-only text boxes (such as the PIN code in Sign-In + template). --> + <item type="color" name="car_app_ui_read_only_text_background_color" /> + <!-- Edit box 'active' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_active_color" /> + <!-- Edit box 'enabled' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_enabled_color" /> + <!-- Edit box 'error' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_error_color" /> + <!-- Edit box 'disabled' text color (such as the username and password in Sign-In template). --> + <item type="color" name="car_app_ui_edit_text_disabled_color"/> + <!-- Text color used in 'clickable spans' (such as the ones allowed in Sign-In template). --> + <item type="color" name="car_app_ui_hyperlink_text_color" /> + <!-- The background color of a row container view to check color contrast against its contents. + This color is used only for color contrast checks, and not for actual background coloring. + Set an appropriate value if the row background color is customized. --> + <item type="color" name="car_app_ui_row_background_color" /> + <!-- The background color of a grid item view to check color contrast against its contents. + This color is used only for color contrast checks, and not for actual background coloring. + Set an appropriate value if the grid background color is customized. --> + <item type="color" name="car_app_ui_grid_item_background_color" /> + + <!-- Vertical space between an image and a text --> + <item type="dimen" name="car_app_ui_image_to_text_spacing_vertical" /> + <!-- Vertical space between a text and a control (such as an edit box to instruction text). --> + <item type="dimen" name="car_app_ui_text_to_control_spacing_vertical" /> + <!-- Vertical space between a text and a secondary control (such as an action button list view to additional text). --> + <item type="dimen" name="car_app_ui_text_to_secondary_control_spacing_vertical" /> + <!-- Vertical space between a control (such as an edit box) and a text. --> + <item type="dimen" name="car_app_ui_control_to_text_spacing_vertical" /> + <!-- Horizontal space between two controls (such two buttons in an Action Strip). --> + <item type="dimen" name="car_app_ui_control_to_control_spacing_horizontal" /> + <!-- Horizontal space around content areas such as full screen lists and grids. --> + <item type="dimen" name="car_app_ui_content_horizontal_margin" /> + <!-- Touch target size, used to define the size of header buttons, for example. --> + <item type="dimen" name="car_app_ui_touch_target_size" /> + <!-- Corner radius used across the UI except for the buttons. --> + <item type="dimen" name="car_app_ui_corner_radius" /> + <!-- Card width (expect for navigation card). If not set, the card width will be defined by + the host in proportion to the screen size. This value must be within the template host + defined range. --> + <item type="dimen" name="car_app_ui_card_width" /> + <!-- Width and height of large images (such as list and grid items, and message and + sign-in images. --> + <item type="dimen" name="car_app_ui_large_image_size" /> + <!-- Vertical space between the nav card content and its container. --> + <item type="dimen" name="car_app_ui_nav_card_padding_vertical" /> + <!-- Horizontal space between the navigation card content and its container. --> + <item type="dimen" name="car_app_ui_nav_card_padding_horizontal" /> + <!-- Horizontal space between an image and a text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_image_to_text_spacing_horizontal" /> + <!-- Vertical space between an image and a text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_image_to_text_spacing_vertical" /> + <!-- Size of xlarge text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_xlarge_text_size" /> + <!-- Size of large text inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_large_text_size" /> + <!-- Vertical space applied in navigation card when lane images are present, for example. --> + <item type="dimen" name="car_app_ui_nav_card_small_padding_vertical" /> + <!-- Navigation card width. If not set, the card width will be defined by the host in + proportion to the screen size. This value must be within the host defined maximum + range. --> + <item type="dimen" name="car_app_ui_nav_card_width" /> + <!-- Size of small images inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_small_image_size" /> + <!-- Size of large images inside a navigation card. --> + <item type="dimen" name="car_app_ui_nav_card_large_image_size" /> + <!-- Size of an image inside a card header. --> + <item type="dimen" name="car_app_ui_card_header_image_size" /> + <!-- Horizontal space between a text (e.g. a title) and the border of a card. --> + <item type="dimen" name="car_app_ui_card_header_text_padding_horizontal" /> + <!-- Vertical space between a text (e.g. a title) and the border of a card. --> + <item type="dimen" name="car_app_ui_card_header_text_padding_vertical" /> + <!-- Horizontal space between a text (e.g. a title) and the border of a card when no header + button is included. --> + <item type="dimen" name="car_app_ui_card_header_no_button_text_margin_start" /> + <!-- Vertical space between grid items --> + <item type="dimen" name="car_app_ui_grid_item_vertical_spacing" /> + <!-- Vertical space between an image and a text inside a grid item. --> + <item type="dimen" name="car_app_ui_grid_item_image_to_text_spacing_vertical" /> + <!-- Vertical space between an two texts inside a grid item. --> + <item type="dimen" name="car_app_ui_grid_item_text_to_text_spacing_vertical" /> + <!-- Buttons height. --> + <item type="dimen" name="car_app_ui_button_height" /> + <!-- Image size inside a button. --> + <item type="dimen" name="car_app_ui_button_image_size" /> + <!-- Horizontal space between the start and end sides of a FAB or button and the action + text. The spacing is applied only when the button only has the text. + If `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right), this value will be ignored. --> + <item type="dimen" name="car_app_ui_button_text_horizontal_spacing" /> + <!-- Horizontal space between the icon and the text in a FAB or button. --> + <item type="dimen" name="car_app_ui_icon_button_image_to_text_spacing" /> + <!-- Horizontal space between the start side of a FAB or button and the action icon. The + spacing is applied only when the button has both icon and text. + If `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right), this value will be ignored. --> + <item type="dimen" name="car_app_ui_icon_button_start_spacing" /> + <!-- Horizontal space between the end side of a FAB or button and the action icon. The + spacing is applied only when the button has both icon and text. + If `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right), this value will be ignored. --> + <item type="dimen" name="car_app_ui_icon_button_end_spacing" /> + <!-- Corner radius applied to buttons. --> + <item type="dimen" name="car_app_ui_button_corner_radius" /> + <!-- The maximum width of a button in the action button list, e.g. used in PaneTemplate. + This value will be used only when `car_app_ui_action_button_list_button_stretch_horizontal` is set to `true`. --> + <item type="dimen" name="car_app_ui_action_button_list_button_max_width" /> + <!-- The horizontal spacing around the content in a button in the action button list, e.g. used in PaneTemplate. + This value will be used only when `car_app_ui_action_button_list_button_content_alignment` is set to 1 (left) or 2 (right). + When this value is used, `car_app_ui_icon_button_start_spacing`, `car_app_ui_icon_button_end_spacing`, and `car_app_ui_button_text_horizontal_spacing` will be ignored. --> + <item type="dimen" name="car_app_ui_button_side_alignment_spacing" /> + <!-- Edit box top vertical space --> + <item type="dimen" name="car_app_ui_edit_text_top_padding" /> + <!-- Edit box bottom vertical space --> + <item type="dimen" name="car_app_ui_edit_text_bottom_padding" /> + <!-- Edit box start side horizontal space --> + <item type="dimen" name="car_app_ui_edit_text_start_padding" /> + <!-- Edit box end side horizontal space --> + <item type="dimen" name="car_app_ui_edit_text_end_padding" /> + <!-- Vertical space between the edit box and the associated error message. --> + <item type="dimen" name="car_app_ui_edit_text_error_vertical_spacing" /> + <!-- Horizontal space between the edit box error message and its container. --> + <item type="dimen" name="car_app_ui_edit_text_error_horizontal_spacing" /> + <!-- Horizontal space around the text in read-only boxes (such as the PIN code in Sign-In + template). --> + <item type="dimen" name="car_app_ui_read_only_text_padding" /> + <!-- Width of a border around or under the edit box, showing the different states of the box. --> + <item type="dimen" name="car_app_ui_edit_text_border_width"/> + <!-- Start padding to list items in full lists (such as ListTemplate) --> + <item type="dimen" name="car_app_ui_full_row_start_padding" /> + <!-- End padding to list items in full lists (such as ListTemplate) --> + <item type="dimen" name="car_app_ui_full_row_end_padding" /> + <!-- Minimum height of a list item in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_min_height" /> + <!-- Horizontal space around list items in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_horizontal_padding" /> + <!-- Vertical space around list items in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_vertical_padding" /> + <!-- Horizontal space between image and text in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_image_to_text_spacing" /> + <!-- Horizontal space between two texts in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_text_to_text_spacing" /> + <!-- Image sizes in half lists (such as PlaceListMapTemplate) --> + <item type="dimen" name="car_app_ui_half_row_image_size" /> + <!-- Sign-in template authentication methods max width. --> + <item type="dimen" name="car_app_ui_sign_in_method_max_width" /> + + <!-- Drawable used for action buttons background. The default value will render these + actions as solid rectangles with rounded corners (corner radius defined by + 'car_app_ui_button_corner_radius'). Background color will be + 'car_app_ui_action_button_default_background_color' or + 'car_app_ui_action_button_primary_background_color', depending on whether the button + is primary or not. + Buttons have the following custom selectors: + <ul> + <li>type_primary: Indicates the button is a primary one. + <li>type_custom: Indicate the colors of this button depend on app provided colors. + </ul> + When a button is marked as 'custom', the app provided background color is applied as a + tint over this drawable. --> + <item type="drawable" name="car_app_ui_action_button_background" /> + + <!-- Maximum number of items to show in a list. This can't be lower than 6 --> + <item type="integer" name="car_app_ui_list_max_length" /> + <!-- Maximum number of items to show in a grid. This can't be lower than 6 --> + <item type="integer" name="car_app_ui_grid_max_length" /> + <!-- Indicates the horizontal order that OEMs pick for the primary action + on selected templates. + <ul> + <li>0 means no re-order + <li>1 indicates primary action should be on the left + <li>2 indicates primary action should be on the right + </ul> + On horizontal buttons, + --> + <item type="integer" name="car_app_ui_action_button_primary_horizontal_order" /> + + <!-- The gravity of action button list (e.g. used in MessageTemplate and PaneTemplate). + The possible values are: + <ul> + <li>0: center (default) + <li>1: bottom + </ul> --> + <item type="integer" name="car_app_ui_action_button_list_gravity" /> + <!-- The alignment of contents in buttons in the action button list (e.g. used in PaneTemplate). + The possible values are: + <ul> + <li>0: center (default) + <li>1: left + <li>2: right + </ul> --> + <item type="integer" name="car_app_ui_action_button_list_button_content_alignment" /> + <!-- Layout gravity for content areas (e.g content vertical alignment in Sign In Template + content).--> + <item type="integer" name="car_app_ui_content_layout_gravity"/> + <!-- Content gravity for content areas (e.g. content horizontal alignment in Sign In + Template content). --> + <item type="integer" name="car_app_ui_content_gravity"/> + + <!-- General paragraph text appareance --> + <item type="style" name="TextAppearance.CarAppUi.TextBlock" /> + <!-- Sign-in header text appareance --> + <item type="style" name="TextAppearance.CarAppUi.SignInHeader" /> + <!-- Sign-in legal notice text appareance --> + <item type="style" name="TextAppearance.CarAppUi.SignInLegal" /> + <!-- Card header appareance (e.g. Place List Template) --> + <item type="style" name="TextAppearance.CarAppUi.CardHeader" /> + <!-- Grid item title text appareance --> + <item type="style" name="TextAppearance.CarAppUi.GridItemTitle" /> + <!-- Grid item description text appareance --> + <item type="style" name="TextAppearance.CarAppUi.GridItemText" /> + <!-- Buttons text appareance --> + <item type="style" name="TextAppearance.CarAppUi.ButtonText" /> + <!-- Read-only text appareance --> + <item type="style" name="TextAppearance.CarAppUi.ReadOnlyText"/> + <!-- Style applied to input views (e.g. Sign-In username box) --> + <item type="style" name="Widget.CarAppUi.InputView" /> + <!-- Style applied to edit boxes --> + <item type="style" name="Widget.CarAppUi.EditText" /> + <!-- Style applied to row sections headers (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowSectionHeader" /> + <!-- Style applied to row title (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowTitle" /> + <!-- Style applied to row secondary text (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowSecondary" /> + <!-- Style applied to list empty text (such as in ListTemplate) --> + <item type="style" name="Widget.CarAppUi.RowListEmpty" /> + </policy> + </overlayable> +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/strings.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/strings.xml new file mode 100644 index 0000000..a925526 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/strings.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<resources> + <!-- Application name [CHAR LIMIT=50] --> + <string name="app_name" translatable="false">AOSP Templates Host</string> + <!-- TODO: Remove or change this placeholder text --> + <string name="hello_blank_fragment">Hello blank fragment</string> +</resources> diff --git a/Host/app/src/main/java/com/android/car/templates/host/res/values/styles_overlayable.xml b/Host/app/src/main/java/com/android/car/templates/host/res/values/styles_overlayable.xml new file mode 100644 index 0000000..2162364 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/res/values/styles_overlayable.xml @@ -0,0 +1,87 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- LINT.IfChange --> +<resources> + <!-- Style definitions that can be overridden by the OEMs. + + !!! IMPORTANT !!! + Do not refer to these styles directly from views. Styles must be referred + to through theme attributes (in attrs.xml). + + Any new resource added to this file should also be added to the + overlayable.xml before OEMs can customize them. --> + + <!-- Template textAppearance --> + <style name="TextAppearance.CarAppUi.TextBlock" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.SignInHeader" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.SignInLegal" parent="TextAppearance.CarUi.Body3" /> + <style name="TextAppearance.CarAppUi.CardHeader" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.GridItemTitle" parent="TextAppearance.CarUi.Body2" /> + <style name="TextAppearance.CarAppUi.GridItemText" parent="TextAppearance.CarUi.Body3"> + <item name="android:textColor">@color/car_ui_text_color_secondary</item> + </style> + <style name="TextAppearance.CarAppUi.ButtonText" parent="TextAppearance.CarUi.Body3" /> + <style name="TextAppearance.CarAppUi.ReadOnlyText" parent="TextAppearance.CarUi.Body3"> + <item name="android:textColor">@color/car_app_ui_read_only_text_color</item> + </style> + + <!-- Input view styling --> + <style name="Widget.CarAppUi.InputView" parent=""> + <item name="android:gravity">start</item> + </style> + + <!-- Edit text styling --> + <style name="Widget.CarAppUi.EditText" parent="android:Widget.DeviceDefault.EditText"> + <item name="android:textColor">@color/default_edit_text_color_selector</item> + <item name="android:textColorHint">@color/default_edit_text_hint_color_selector</item> + <item name="android:paddingTop">@dimen/car_app_ui_edit_text_top_padding</item> + <item name="android:paddingBottom">@dimen/car_app_ui_edit_text_bottom_padding</item> + <item name="android:paddingStart">@dimen/car_app_ui_edit_text_start_padding</item> + <item name="android:paddingEnd">@dimen/car_app_ui_edit_text_end_padding</item> + <item name="android:background">@drawable/default_edit_text_background</item> + <item name="android:foreground">@drawable/default_edit_text_foreground</item> + </style> + + <!-- The style of the list section header. --> + <style name="Widget.CarAppUi.RowSectionHeader" parent="TextAppearance.CarUi.ListItem.Header"> + <item name="android:textAlignment">textStart</item> + <item name="android:layout_marginStart">@dimen/car_ui_padding_4</item> + <item name="android:layout_marginVertical">@dimen/car_ui_padding_2</item> + </style> + + <!-- The style of the title text in a list row. --> + <style name="Widget.CarAppUi.RowTitle" parent=""> + <item name="android:textAppearance">@style/TextAppearance.CarUi.ListItem</item> + <item name="android:textAlignment">viewStart</item> + <item name="android:singleLine">@bool/car_ui_list_item_single_line_title</item> + </style> + + <!-- The style of the secondary text in a list row. --> + <style name="Widget.CarAppUi.RowSecondary" parent=""> + <item name="android:textAppearance">@style/TextAppearance.CarUi.ListItem.Body</item> + <item name="android:textAlignment">viewStart</item> + </style> + + <!-- The style of text that indicates a list is empty --> + <style name="Widget.CarAppUi.RowListEmpty" parent="Widget.CarAppUi.RowSecondary"> + <item name="android:maxLines">2</item> + <item name="android:gravity">center</item> + </style> + +</resources> +<!-- LINT.ThenChange(overlayable.xml) --> diff --git a/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapStub.java b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapStub.java new file mode 100644 index 0000000..4bb11c1 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapStub.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.templates.host.view.widgets.maps; + +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import androidx.annotation.Nullable; +import androidx.car.app.CarToast; +import androidx.car.app.model.CarColor; +import androidx.car.app.model.CarLocation; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.logging.LogTags; +import java.util.ArrayList; +import java.util.Arrays; + +/** A mock map used to simulate all the APIs of a actual map. */ +public class MapStub { + /** A listener that is called when any parameter of this map changes. */ + private OnMapUpdateListener mOnMapUpdateListener; + + private final UiSettings mUiSettings; + private final ArrayList<Marker> mMarkers = new ArrayList<>(); + + private OnMarkerClickListener mOnMarkerClickListener; + private CarLocation mCarLocation; + + private boolean mIsLocationEnabled; + private boolean mHasAnimation; + + private float mMaxZoomPreference; + private int mAnimationDuration; + private int mPaddingLeft; + private int mPaddingRight; + private int mPaddingTop; + private int mPaddingBottom; + + /** Instantiates a new mock map. */ + public MapStub() { + mUiSettings = new UiSettings(); + } + + /** sets OnMapUpdateListener for this map. */ + public void setOnMapUpdateListener(OnMapUpdateListener onMapUpdateListener) { + mOnMapUpdateListener = onMapUpdateListener; + } + + /** sets MaxZoomPreference for this map. */ + public void setMaxZoomPreference(float maxZoomPreference) { + Log.d(LogTags.TEMPLATE, "MaxZoomPreference is updated, " + maxZoomPreference); + mMaxZoomPreference = maxZoomPreference; + update(); + } + + /** sets OnMarkerClickListener for this map. */ + public void setOnMarkerClickListener(MapStub.OnMarkerClickListener listener) { + Log.d(LogTags.TEMPLATE, "setOnMarkerClickListener"); + mOnMarkerClickListener = listener; + update(); + } + + /** Enables location. */ + public void setMyLocationEnabled(boolean enabled) { + Log.d(LogTags.TEMPLATE, "setMyLocationEnabled: " + enabled); + mIsLocationEnabled = enabled; + update(); + } + + /** Sets paddings. */ + public void setPadding(int left, int top, int right, int bottom) { + Log.d(LogTags.TEMPLATE, "setPaddings: " + left + ", " + top + ", " + right + ", " + bottom); + mPaddingLeft = left; + mPaddingRight = right; + mPaddingTop = top; + mPaddingBottom = bottom; + update(); + } + + /** Add a marker. */ + public void addMarker(Marker marker) { + if (marker == null) { + return; + } + Log.d(LogTags.TEMPLATE, "add a marker" + marker); + mMarkers.add(marker); + update(); + } + + /** Remove a marker. */ + public boolean removeMarker(Marker marker) { + if (marker == null || !mMarkers.contains(marker)) { + return false; + } + Log.d(LogTags.TEMPLATE, "remove a marker" + marker); + mMarkers.remove(marker); + update(); + return true; + } + + /** Returns UiSettings. */ + public UiSettings getUiSettings() { + return mUiSettings; + } + + /** Updates the camera. */ + public void animateCamera(CarLocation location, int animationDuration) { + Log.d(LogTags.TEMPLATE, "move camera with animation to " + location); + mCarLocation = location; + mHasAnimation = true; + mAnimationDuration = animationDuration; + update(); + } + + /** Updates the camera. */ + public void moveCamera(CarLocation location) { + Log.d(LogTags.TEMPLATE, "Move Camera to: " + location); + mCarLocation = location; + mHasAnimation = false; + mAnimationDuration = 0; + update(); + } + + private void update() { + mOnMapUpdateListener.onMapUpdate(); + } + + @Override + public String toString() { + StringBuilder mapInfo = new StringBuilder(); + String newLine = System.lineSeparator(); + mapInfo + .append(String.format("UiSettings: %s", mUiSettings)) + .append(newLine) + .append(String.format("setMaxZoomPreference to be %f", mMaxZoomPreference)) + .append(newLine) + .append(String.format("setOnMarkerClickListener %s", mOnMarkerClickListener)) + .append(newLine) + .append(String.format("setMyLocationEnabled: %s", mIsLocationEnabled)) + .append(newLine) + .append( + String.format( + "setPaddings: %d, %d, %d, %d", + mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom)) + .append(newLine) + .append(String.format("Markers: %s", Arrays.toString(mMarkers.toArray()))) + .append(newLine) + .append(String.format("move camera to %s", mCarLocation)) + .append(newLine) + .append( + (mHasAnimation + ? String.format("with animation (duration: %d)", mAnimationDuration) + : "without animation")); + return mapInfo.toString(); + } + + /** A listener that is called when any parameter of this map changes. */ + public interface OnMapUpdateListener { + /** Called when map is updated. */ + void onMapUpdate(); + } + + /** A mock Marker class. */ + public static class Marker { + private final CarLocation mLocation; + @Nullable private final CarColor mMarkerColor; + + /** Instantiates a marker */ + public Marker(CarLocation location, @Nullable CarColor color) { + mLocation = location; + mMarkerColor = color; + } + + @Override + public String toString() { + return "Marker location: " + mLocation + ", " + "color: " + mMarkerColor; + } + } + + /** A mock OnMarkerClickListener class. */ + public static class OnMarkerClickListener implements View.OnTouchListener { + + /** Called when a touch event is dispatched. */ + @Override + public boolean onTouch(View v, MotionEvent event) { + float positionX = event.getX(); + float positionY = event.getY(); + Log.d(LogTags.TEMPLATE, "OnMarkerClickListener: X: " + positionX + ", Y: " + positionY); + ((TemplateContext) v.getContext()) + .getToastController() + .showToast( + "OnMarkerClickListener: X: " + positionX + ", Y: " + positionY, CarToast.LENGTH_LONG); + return true; + } + } + + /** A mock class for UiSettings. */ + public static class UiSettings { + private boolean mIsMyLocationButtonEnabled; + private boolean mIsAllGesturesEnabled; + + public void setMyLocationButtonEnabled(boolean enabled) { + mIsMyLocationButtonEnabled = enabled; + } + + public void setAllGesturesEnabled(boolean enabled) { + mIsAllGesturesEnabled = enabled; + } + + @Override + public String toString() { + return "IsMyLocationButtonEnabled: " + + mIsMyLocationButtonEnabled + + ", IsAllGesturesEnabled: " + + mIsAllGesturesEnabled; + } + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapViewStub.java b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapViewStub.java new file mode 100644 index 0000000..523d855 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapViewStub.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.templates.host.view.widgets.maps; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.widget.FrameLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** A Map view that holds mock map. */ +public class MapViewStub extends FrameLayout { + private MapStub mMap; + private TextView mTextView; + + public MapViewStub(@NonNull Context context) { + super(context); + } + + public MapViewStub(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + /** + * Instantiates a new map view. + * + * @see android.content.res.Resources.Theme#obtainStyledAttributes + */ + public MapViewStub(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + /** Instantiates a new map view. */ + @SuppressWarnings("nullness:argument") + public MapViewStub( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + LayoutInflater.from(context).inflate(com.android.car.templates.host.R.layout.map_stub_layout, this); + mTextView = findViewById(com.android.car.templates.host.R.id.map_info); + mMap = new MapStub(); + mMap.setOnMapUpdateListener(() -> mTextView.setText(mMap.toString())); + } + + /** + * Return a mock map. This method is a substitute of getMapAsync() in other map implementation. + */ + public MapStub getMap() { + return mMap; + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapViewStubContainer.java b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapViewStubContainer.java new file mode 100644 index 0000000..3f4bf7f --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/MapViewStubContainer.java @@ -0,0 +1,362 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.templates.host.view.widgets.maps; + +import static java.util.Objects.requireNonNull; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.util.Log; +import android.util.TypedValue; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.car.app.CarToast; +import androidx.car.app.model.CarLocation; +import androidx.car.app.model.Place; +import androidx.car.app.model.PlaceMarker; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LifecycleRegistry; +import com.android.car.libraries.apphost.common.LocationMediator; +import com.android.car.libraries.apphost.common.TemplateContext; +import com.android.car.libraries.apphost.logging.LogTags; +import com.android.car.libraries.apphost.view.widget.map.AbstractMapViewContainer; +import com.android.car.templates.host.view.widgets.maps.MapStub.Marker; +import com.google.common.collect.ImmutableList; +import dagger.hilt.android.qualifiers.ApplicationContext; +import java.util.ArrayList; +import java.util.List; + +/** A layout that wraps a single map view and encapsulates the logic to manipulate it. */ +public class MapViewStubContainer extends AbstractMapViewContainer { + // Strings indicating the reason for a view update used for logging purposes. + private static final String UPDATE_REASON_SET_PLACES = "set_places"; + private static final String UPDATE_REASON_SET_ANCHOR = "set_anchor"; + private static final String UPDATE_REASON_MAP_INSETS = "map_insets"; + private static final String UPDATE_REASON_ON_CREATE = "on_create"; + private static final String UPDATE_REASON_ON_START = "on_start"; + private static final int NUMBER_OF_MARKERS = 8; + + @SuppressWarnings("nullness") + private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this); + + private final MapViewStub mMapView; + /** The max zoom level to ever reach in the map view. */ + private final float mMaxZoomLevel; + + private final ArrayList<Marker> mMarkers = new ArrayList<>(NUMBER_OF_MARKERS); + + private TemplateContext mTemplateContext; + private MapStub mMap; + + /** {@code true} when the view is started, {@code false} when stopped. */ + private boolean mIsStarted; + + private boolean mIsAnchorDirty; + private boolean mArePlacesDirty; + + @Nullable private Marker mAnchorMarker; + private Place mAnchor; + + /** whether the map should show the current location. */ + private boolean mCurrentLocationEnabled = false; + + /** A list with the places displayed on the map. */ + private List<Place> mPlaces = ImmutableList.of(); + + /** + * Whether the view has ever completed a successful update. We use this to know whether the camera + * needs to be animated or not. + */ + private boolean mHasUpdated = false; + + /** + * Instantiates a new map view container. + * + * @see android.content.res.Resources.Theme#obtainStyledAttributes + */ + @SuppressWarnings("nullness") + public MapViewStubContainer( + Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + LayoutInflater.from(context).inflate(com.android.car.templates.host.R.layout.map_view_stub, this); + mMapView = findViewById(com.android.car.templates.host.R.id.map_view); + + TypedValue outValue = new TypedValue(); + getResources().getValue(com.android.car.templates.host.R.dimen.map_max_zoom_level, outValue, true); + mMaxZoomLevel = outValue.getFloat(); + + mLifecycleRegistry.addObserver(this); + } + + /** Instantiates a new map view container. */ + public MapViewStubContainer(@ApplicationContext Context context) { + this(context, null); + } + + /** + * Instantiates a new map view container. + * + * @see android.content.res.Resources.Theme#obtainStyledAttributes + */ + public MapViewStubContainer(Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + /** + * Instantiates a new map view container. + * + * @see android.content.res.Resources.Theme#obtainStyledAttributes + */ + public MapViewStubContainer(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + /** Returns an AOSPMapViewContainer */ + public static AbstractMapViewContainer create(Context context, int theme) { + return (AbstractMapViewContainer) + View.inflate( + new ContextThemeWrapper(context, theme), com.android.car.templates.host.R.layout.map_view_stub_container_layout, null); + } + + /** Returns an AOSPMapViewContainer */ + public static MapViewStubContainer create(Context context) { + return (MapViewStubContainer) + View.inflate(context, com.android.car.templates.host.R.layout.map_view_stub_container_layout, null); + } + + @Override + public void setTemplateContext(TemplateContext templateContext) { + mTemplateContext = templateContext; + } + + @NonNull + @Override + public Lifecycle getLifecycle() { + return mLifecycleRegistry; + } + + @NonNull + @Override + public LifecycleRegistry getLifecycleRegistry() { + return mLifecycleRegistry; + } + + @Override + public void onCreate(LifecycleOwner owner) { + mMap = mMapView.getMap(); + + // Set the maximum zoom level, so that when we update the camera, it doesn't go past + // this value. The camera update logic we use tries to set the camera at the maximum + // level of zoom possible given a set of places to bind it to. + mMap.setMaxZoomPreference(mMaxZoomLevel); + + // Updates the insets of the map to ensure the markers aren't drawn behind any other + // widgets on the screen. + updateMapInsets(mMap); + + // Returns true to disable marker click events globally. This disables the default + // behavior where clicking on a marker centers it on the map. + mMap.setOnMarkerClickListener(new MapStub.OnMarkerClickListener()); + + MapStub.UiSettings uiSettings = mMap.getUiSettings(); + uiSettings.setMyLocationButtonEnabled(false); + uiSettings.setAllGesturesEnabled(false); + + mMap.setMyLocationEnabled(mCurrentLocationEnabled); + update(UPDATE_REASON_ON_CREATE); + } + + @Override + public void onStart(LifecycleOwner owner) { + mIsStarted = true; + update(UPDATE_REASON_ON_START); + } + + @Override + public void onStop(LifecycleOwner owner) { + mIsStarted = false; + } + + @Override + public void setCurrentLocationEnabled(boolean enable) { + mCurrentLocationEnabled = true; + mMap.setMyLocationEnabled(mCurrentLocationEnabled); + } + + /** Sets the map anchor. The camera will be adjusted to include the anchor marker if necessary. */ + @Override + @SuppressWarnings("nullness:assignment") + public void setAnchor(@Nullable Place anchor) { + mIsAnchorDirty = true; + mAnchor = anchor; + update(UPDATE_REASON_SET_ANCHOR); + } + + /** + * Sets the places to display in the map. The camera will be moved to the region that contains all + * the places. + */ + @Override + public void setPlaces(List<Place> places) { + if (mPlaces.containsAll(places) && places.containsAll(mPlaces)) { + return; + } + mArePlacesDirty = true; + mPlaces = places; + update(UPDATE_REASON_SET_PLACES); + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public List<Place> getPlaces() { + return mPlaces; + } + + private void update(String updateReason) { + // Three conditions need to happen before we update the view: + // 1. the map needs to be initialized, + // 2. the view must have gone through a layout pass (so that it can calculate the viewport + // rect for the camera), + // 3. the view must be in STARTED state. + if (mMap == null || !mMapView.isLaidOut() || !mIsStarted) { + return; + } + + Log.d(LogTags.TEMPLATE, "Updating map view, reason: " + updateReason); + + // Do not animate the camera the very first time, but animate it in any subsequent updates. + updateCamera(/* animate= */ mHasUpdated); + updateAnchorMarker(); + updatePlaceMarkers(); + mHasUpdated = true; + } + + private void updatePlaceMarkers() { + if (mMap == null || !mArePlacesDirty) { + return; + } + + Log.d(LogTags.TEMPLATE, "Updating map location markers"); + + // Clean up the existing markers. + for (MapStub.Marker marker : mMarkers) { + mMap.removeMarker(marker); + } + mMarkers.clear(); + + // Add the new ones. + for (Place place : mPlaces) { + PlaceMarker marker = place.getMarker(); + CarLocation location = place.getLocation(); + + if (location != null) { + // Skip null (invisible) markers. + if (marker != null) { + mMap.addMarker(new MapStub.Marker(location, marker.getColor())); + } + } else { + Log.w(LogTags.TEMPLATE, "Place location is expected but not set: " + place); + } + } + + mArePlacesDirty = false; + } + + private void updateAnchorMarker() { + if (mMap == null || !mIsAnchorDirty) { + return; + } + MapStub map = mMap; + + Log.d(LogTags.TEMPLATE, "Updating map anchor marker"); + + // Clean up the existing marker. + if (mAnchorMarker != null) { + map.removeMarker(mAnchorMarker); + } + + // Add the new one. + if (mAnchor != null) { + PlaceMarker marker = mAnchor.getMarker(); + CarLocation location = mAnchor.getLocation(); + + if (location != null) { + if (marker != null) { + map.addMarker(new MapStub.Marker(location, requireNonNull(marker.getColor()))); + } + } else { + Log.w(LogTags.TEMPLATE, "Anchor location is expected but not set: " + mAnchor); + } + + mIsAnchorDirty = false; + } + } + + private void updateCamera(boolean animate) { + boolean hasPlace = false; + CarLocation location = mAnchor != null ? mAnchor.getLocation() : null; + + if (location != null) { + hasPlace = true; + } else { + Log.w( + LogTags.TEMPLATE, + "Anchor location is expected but not set, excluding from camera: " + mAnchor); + } + + LocationMediator mediator = + requireNonNull(mTemplateContext.getAppHostService(LocationMediator.class)); + if (!hasPlace) { + // Try to maintain the previous camera location if available. + CarLocation anchor = mediator.getCameraAnchor(); + if (anchor == null) { + return; + } + + location = anchor; + } + + mediator.setCameraAnchor(location); + if (animate) { + mMap.animateCamera(requireNonNull(location), CarToast.LENGTH_SHORT); + } else { + mMap.moveCamera(requireNonNull(location)); + } + } + + private void updateMapInsets(MapStub map) { + if (mTemplateContext == null) { + return; + } + Rect stableArea = mTemplateContext.getSurfaceInfoProvider().getStableArea(); + stableArea = stableArea != null ? stableArea : new Rect(0, 0, getWidth(), getHeight()); + + if (map != null) { + map.setPadding( + stableArea.left, + stableArea.top, + getWidth() - stableArea.right, + getHeight() - stableArea.bottom); + update(UPDATE_REASON_MAP_INSETS); + } + } +} diff --git a/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_stub_layout.xml b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_stub_layout.xml new file mode 100644 index 0000000..50051be --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_stub_layout.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<androidx.constraintlayout.widget.ConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + <TextView + android:id="@+id/map_info" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:textSize="@dimen/map_text_size" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_view_stub.xml b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_view_stub.xml new file mode 100644 index 0000000..0106df6 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_view_stub.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.templates.host.view.widgets.maps.MapViewStub + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/map_view" + android:layout_width="match_parent" + android:layout_height="match_parent"/> diff --git a/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_view_stub_container_layout.xml b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_view_stub_container_layout.xml new file mode 100644 index 0000000..0d42dc1 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/layout/map_view_stub_container_layout.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.templates.host.view.widgets.maps.MapViewStubContainer + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/map_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:descendantFocusability="blocksDescendants" + android:focusable="false" + app:markerAppearance="?templateMapMarkerAppearance"> + + +</com.android.car.templates.host.view.widgets.maps.MapViewStubContainer> diff --git a/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/values/dimens.xml b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/values/dimens.xml new file mode 100644 index 0000000..0c3f320 --- /dev/null +++ b/Host/app/src/main/java/com/android/car/templates/host/view/widgets/maps/res/values/dimens.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<resources> + <!-- The max level of zoom to ever reach in the map view. + A value of 15 maps roughly to street level and 20 maps to building level. + See https://developers.google.com/maps/documentation/android-sdk/views. --> + <item name="map_max_zoom_level" format="float" type="dimen">17</item> + <dimen name="map_text_size">40sp</dimen> +</resources> diff --git a/Host/build.gradle b/Host/build.gradle new file mode 100644 index 0000000..3ec9f3b --- /dev/null +++ b/Host/build.gradle @@ -0,0 +1,18 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + + dependencies { + classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1' + classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.18' + } +} + +plugins { + id 'com.android.application' version '7.2.0-alpha06' apply false + id 'com.android.library' version '7.2.0-alpha06' apply false + id 'org.jetbrains.kotlin.android' version '1.6.0' apply false +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/Host/gradle.properties b/Host/gradle.properties new file mode 100644 index 0000000..d5e8af3 --- /dev/null +++ b/Host/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true diff --git a/Host/gradle/wrapper/gradle-wrapper.jar b/Host/gradle/wrapper/gradle-wrapper.jar Binary files differnew file mode 100644 index 0000000..e708b1c --- /dev/null +++ b/Host/gradle/wrapper/gradle-wrapper.jar diff --git a/Host/gradle/wrapper/gradle-wrapper.properties b/Host/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d681304 --- /dev/null +++ b/Host/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Feb 08 16:11:22 PST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/Host/gradlew b/Host/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/Host/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/Host/gradlew.bat b/Host/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/Host/gradlew.bat @@ -0,0 +1,89 @@ +@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/Host/local.properties b/Host/local.properties new file mode 100644 index 0000000..1a291d6 --- /dev/null +++ b/Host/local.properties @@ -0,0 +1,2 @@ +# Please set the system sdk path +sdk.dir=${path_to_android-system-sdk} diff --git a/Host/settings.gradle b/Host/settings.gradle new file mode 100644 index 0000000..5c580ea --- /dev/null +++ b/Host/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +gradle.ext.aaosLatestSDK = 31 + +rootProject.name = "AOSPTemplateshost" +include ':app' +include ':app:apphost' +include ':app:renderer' + +gradle.ext.lib_car_system_stubs = rootDir.absolutePath + "../../../../../../prebuilts/sdk/" + gradle.ext.aaosLatestSDK + "/system/android.car-system-stubs.jar" +gradle.ext.lib_system_stubs = rootDir.absolutePath + "../../../../../../prebuilts/sdk/" + gradle.ext.aaosLatestSDK + "/system/android.jar" @@ -0,0 +1,3 @@ +igorr@google.com +babakbo@google.com +calhuang@google.com
\ No newline at end of file |