/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.car.app;
import static android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY;
import static androidx.annotation.RestrictTo.Scope.LIBRARY;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import static androidx.car.app.utils.LogTags.TAG;
import static java.util.Objects.requireNonNull;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.Intent;
import android.content.res.Configuration;
import android.hardware.display.DisplayManager;
import android.hardware.display.VirtualDisplay;
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.OnBackPressedDispatcher;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
import androidx.annotation.StringDef;
import androidx.car.app.annotations.RequiresCarApi;
import androidx.car.app.navigation.NavigationManager;
import androidx.car.app.utils.RemoteUtils;
import androidx.car.app.utils.ThreadUtils;
import androidx.car.app.versioning.CarAppApiLevel;
import androidx.car.app.versioning.CarAppApiLevels;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.security.InvalidParameterException;
/**
* The CarContext class is a {@link ContextWrapper} subclass accessible to your {@link
* CarAppService} and {@link Screen} instances, which provides access to car services such as the
* {@link ScreenManager} for managing the screen stack, the {@link AppManager} for general
* app-related functionality such as accessing a surface for drawing your navigation app's map, and
* the {@link NavigationManager} used by turn-by-turn navigation apps to communicate navigation
* metadata and other navigation-related events with the host. See Access the navigation templates
* for a comprehensive list of library functionality available to navigation apps.
*
* <p>Whenever you use a CarContext to load resources, the following configuration elements come
* from the car screen's configuration, and not the phone:
*
* <ul>
* <li>Screen width.
* <li>Screen height.
* <li>Screen pixel density (DPI).
* <li>Night mode (See {@link #isDarkMode}).
* </ul>
*
* <p>Please refer <a
* href="https://developer.android.com/guide/topics/resources/providing-resources">here</a>, on how
* to use configuration qualifiers in your resources.
*
* @see #getCarService
*/
public class CarContext extends ContextWrapper {
/**
* Represents the types of services for client-host communication.
*
* @hide
*/
@StringDef({APP_SERVICE, CAR_SERVICE, NAVIGATION_SERVICE, SCREEN_SERVICE})
@Retention(RetentionPolicy.SOURCE)
public @interface CarServiceType {
}
/** Manages all app events such as invalidating the UI, showing a toast, etc. */
public static final String APP_SERVICE = "app";
/**
* Manages all navigation events such as starting navigation when focus is granted, abandoning
* navigation when focus is lost, etc.
*/
public static final String NAVIGATION_SERVICE = "navigation";
/** Manages the screens of the app, including the screen stack. */
public static final String SCREEN_SERVICE = "screen";
/**
* Internal usage only. Top level binder to host.
*/
public static final String CAR_SERVICE = "car";
/**
* Key for including a IStartCarApp in the notification {@link Intent}, for starting the app
* if it has not been opened yet.
*/
public static final String EXTRA_START_CAR_APP_BINDER_KEY = "androidx.car.app.extra"
+ ".START_CAR_APP_BINDER_KEY";
/**
* Standard action for navigating to a location.
*
* <p>Used as the {@link Intent}'s action for starting a navigation via {@link #startCarApp}.
*/
public static final String ACTION_NAVIGATE =
"androidx.car.app.action.NAVIGATE";
private final AppManager mAppManager;
private final NavigationManager mNavigationManager;
private final ScreenManager mScreenManager;
private final OnBackPressedDispatcher mOnBackPressedDispatcher;
private final HostDispatcher mHostDispatcher;
/** API level, updated once host connection handshake is completed. */
@CarAppApiLevel
private int mCarAppApiLevel = CarAppApiLevels.UNKNOWN;
/** @hide */
@NonNull
@RestrictTo(LIBRARY)
public static CarContext create(@NonNull Lifecycle lifecycle) {
return new CarContext(lifecycle, new HostDispatcher());
}
/**
* Provides a car service by name.
*
* <p>The class of the returned object varies by the requested name.
*
* <p>Currently supported car services, and their respective classes, are:
*
* <dl>
* <dt>{@link #APP_SERVICE}
* <dd>An {@link AppManager} for communication between the app and the host.
* <dt>{@link #NAVIGATION_SERVICE}
* <dd>A {@link NavigationManager} for management of navigation updates.
* <dt>{@link #SCREEN_SERVICE}
* <dd>A {@link ScreenManager} for management of {@link Screen}s.
* </dl>
*
* @param name The name of the car service requested. This should be one of
* {@link #APP_SERVICE},
* {@link #NAVIGATION_SERVICE} or {@link #SCREEN_SERVICE}
* @return The car service instance
* @throws IllegalArgumentException if {@code name} does not refer to a valid car service
* @throws NullPointerException if {@code name} is {@code null}
*/
// This is kept for the testing library.
@NonNull
public Object getCarService(@CarServiceType @NonNull String name) {
switch (requireNonNull(name)) {
case APP_SERVICE:
return mAppManager;
case NAVIGATION_SERVICE:
return mNavigationManager;
case SCREEN_SERVICE:
return mScreenManager;
default: // fall out
}
throw new IllegalArgumentException(
"The name '" + name + "' does not correspond to a car service");
}
/**
* Returns the a car service, by class.
*
* <p>Currently supported classes are: {@link AppManager}, {@link NavigationManager}, {@link
* ScreenManager}.
*
* @param serviceClass the class of the requested service
* @throws IllegalArgumentException if {@code serviceClass} is not the class of a supported car
* service
* @throws NullPointerException if {@code serviceClass} is {@code null}
*/
@NonNull
public <T> T getCarService(@NonNull Class<T> serviceClass) {
return requireNonNull(serviceClass).cast(getCarService(getCarServiceName(serviceClass)));
}
/**
* Gets the name of the car service that is represented by the specified class.
*
* @param serviceClass the class of the requested service
* @return the car service name to use with {@link #getCarService(String)}
* @throws IllegalArgumentException if {@code serviceClass} is not the class of a supported car
* service
* @throws NullPointerException if {@code serviceClass} is {@code null}
* @see #getCarService
*/
@NonNull
@CarServiceType
public String getCarServiceName(@NonNull Class<?> serviceClass) {
if (requireNonNull(serviceClass).isInstance(mAppManager)) {
return APP_SERVICE;
} else if (serviceClass.isInstance(mNavigationManager)) {
return NAVIGATION_SERVICE;
} else if (serviceClass.isInstance(mScreenManager)) {
return SCREEN_SERVICE;
}
throw new IllegalArgumentException("The class does not correspond to a car service");
}
/**
* Starts a car app on the car screen.
*
* <p>The target application will get the {@link Intent} via {@link Session#onCreateScreen}
* or {@link Session#onNewIntent}.
*
* <p>Supported {@link Intent}s:
*
* <dl>
* <dt>An {@link Intent} to navigate.
* <dd>The action must be {@link #ACTION_NAVIGATE}.
* <dd>The data URI scheme must be either a latitude,longitude pair, or a + separated string
* query as follows:
* <dd>1) "geo:12.345,14.8767" for a latitude, longitude pair.
* <dd>2) "geo:0,0?q=123+Main+St,+Seattle,+WA+98101" for an address.
* <dd>3) "geo:0,0?q=a+place+name" for a place to search for.
* <dt>An {@link Intent} to make a phone call.
* <dd>The {@link Intent} must be created as defined <a
* href="https://developer.android
* .com/guide/components/intents-common#DialPhone">here</a>.
* <dt>An {@link Intent} to start this app in the car.
* <dd>The component name of the intent must be the one for the {@link CarAppService} that
* contains this {@link CarContext}. If the component name is for a different
* component, the
* method will throw a {@link SecurityException}.
* </dl>
*
* @param intent the {@link Intent} to send to the target application
* @throws SecurityException if the app attempts to start a different app explicitly or
* does not have permissions for the requested action
* @throws InvalidParameterException if {@code intent} does not meet the criteria defined
* @throws NullPointerException if {@code intent} is {@code null}
*/
public void startCarApp(@NonNull Intent intent) {
requireNonNull(intent);
mHostDispatcher.dispatch(
CarContext.CAR_SERVICE,
"startCarApp", (ICarHost host) -> {
host.startCarApp(intent);
return null;
}
);
}
/**
* Starts the car app on the car screen.
*
* <p>Use this method if the app has received a broadcast due to a notification action.
*
* @param notificationIntent the {@link Intent} that the app received via broadcast due to a
* user taking an action on a notification in the car
* @param appIntent the {@link Intent} to use for starting the car app. See {@link
* #startCarApp(Intent)} for the documentation on valid
* {@link Intent}s
* @throws InvalidParameterException if {@code notificationIntent} is not an {@link Intent}
* received from a broadcast, due to an action taken by the
* user in the car
* @throws NullPointerException if either {@code notificationIntent} or {@code appIntent
* } are {@code null}
*/
public static void startCarApp(@NonNull Intent notificationIntent, @NonNull Intent appIntent) {
requireNonNull(notificationIntent);
requireNonNull(appIntent);
IBinder binder = null;
Bundle extras = notificationIntent.getExtras();
if (extras != null) {
binder = extras.getBinder(EXTRA_START_CAR_APP_BINDER_KEY);
}
if (binder == null) {
throw new IllegalArgumentException("Notification intent missing expected extra");
}
IStartCarApp startCarAppInterface = requireNonNull(IStartCarApp.Stub.asInterface(binder));
RemoteUtils.dispatchCallToHost(
"startCarApp from notification", () -> {
startCarAppInterface.startCarApp(appIntent);
return null;
}
);
}
/**
* Requests to finish the car app.
*
* <p>Call this when your app is done and should be closed. The {@link Session} corresponding
* to this {@link CarContext} will become {@code State.DESTROYED}.
*
* <p>At some point after this call, the OS will destroy your {@link CarAppService}.
*/
public void finishCarApp() {
mHostDispatcher.dispatch(
CarContext.CAR_SERVICE,
"finish", (ICarHost host) -> {
host.finish();
return null;
}
);
}
/**
* Returns {@code true} if the car is set to dark mode.
*
* <p>Navigation applications must redraw their map with the proper dark colors when the host
* determines that conditions warrant it, as signaled by the value returned by this method.
*
* <p>Whenever the dark mode status changes, you will receive a call to {@link
* Session#onCarConfigurationChanged}.
*/
public boolean isDarkMode() {
return (getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)
== Configuration.UI_MODE_NIGHT_YES;
}
/**
* Returns the {@link OnBackPressedDispatcher} that will be triggered when the user clicks a
* back button.
*
* <p>The default back press behavior is to call {@link ScreenManager#pop}.
*
* <p>To override the default behavior, register a
* {@link androidx.activity.OnBackPressedCallback} via calling
* {@link OnBackPressedDispatcher#addCallback(LifecycleOwner, OnBackPressedCallback)}. Using
* the {@link LifecycleOwner} ensures that your callback is only registered while its
* {@link Lifecycle} is at least {@link Lifecycle.State#STARTED}.
*
* <p>If there is a {@link androidx.activity.OnBackPressedCallback} that is added and
* enabled, and you'd like to remove the top {@link Screen} as a part of the callback, you
* <b>MUST</b> call {@link ScreenManager#pop} in the callback. The default behavior is
* overridden when you have a callback enabled.
*/
@NonNull
public OnBackPressedDispatcher getOnBackPressedDispatcher() {
return mOnBackPressedDispatcher;
}
/**
* Copies the fields from the provided {@link Configuration} into the {@link Configuration}
* contained in this object.
*
* @hide
*/
@RestrictTo(LIBRARY)
@MainThread
@SuppressWarnings("deprecation")
void onCarConfigurationChanged(@NonNull Configuration configuration) {
ThreadUtils.checkMainThread();
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG,
"Car configuration changed, configuration: " + configuration
+ ", displayMetrics: "
+ getResources().getDisplayMetrics());
}
getResources()
.updateConfiguration(requireNonNull(configuration),
getResources().getDisplayMetrics());
}
/**
* Updates context information based on the information provided during connection handshake
*
* @hide
*/
@RestrictTo(LIBRARY)
@MainThread
void updateHandshakeInfo(HandshakeInfo handshakeInfo) {
mCarAppApiLevel = handshakeInfo.getHostCarAppApiLevel();
}
/**
* Attaches the base {@link Context} for this {@link CarContext} by creating a new display
* context using {@link #createDisplayContext} with a {@link VirtualDisplay} created using
* the metrics from the provided {@link Configuration}, and then also calling {@link
* #createConfigurationContext} with the provided {@link Configuration}.
*
* <p>This call creates a display context and then a configuration context to ensure that
* updates to the phone configuration do not update either the {@link Configuration} or {@link
* android.util.DisplayMetrics} held by this {@link CarContext}'s resources.
*
* @hide
*/
@RestrictTo(LIBRARY)
@MainThread
void attachBaseContext(@NonNull Context context, @NonNull Configuration configuration) {
ThreadUtils.checkMainThread();
// If this is the first time attaching the base, actually attach it, otherwise, just
// update the configuration.
if (getBaseContext() == null) {
// Create the virtual display with the proper dimensions.
VirtualDisplay display =
((DisplayManager) requireNonNull(
context.getSystemService(Context.DISPLAY_SERVICE)))
.createVirtualDisplay(
"CarAppService",
configuration.screenWidthDp,
configuration.screenHeightDp,
configuration.densityDpi,
null,
VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY);
attachBaseContext(
context
.createDisplayContext(display.getDisplay())
.createConfigurationContext(configuration));
}
onCarConfigurationChanged(configuration);
}
/** @hide */
@RestrictTo(LIBRARY_GROUP) // Restrict to testing library
@MainThread
void setCarHost(@NonNull ICarHost carHost) {
ThreadUtils.checkMainThread();
mHostDispatcher.setCarHost(requireNonNull(carHost));
}
/**
* Retrieves the API level negotiated with the host.
*
* <p>API levels are used during client and host connection handshake to negotiate a common set
* of elements that both processes can understand. Different devices might have different host
* versions. Each of these hosts will support a range of API levels, as a way to provide
* backwards compatibility.
*
* <p>Applications can also provide forward compatibility, by declaring support for a
* {@link AppInfo#getMinCarAppApiLevel()} lower than {@link AppInfo#getLatestCarAppApiLevel()}.
* See {@link AppInfo#getMinCarAppApiLevel()} for more details.
*
* <p>Clients must ensure no elements annotated with a {@link RequiresCarApi} value higher
* than returned by this method is used at runtime.
*
* <p>Please refer to {@link RequiresCarApi} description for more details on how to
* implement forward compatibility.
*
* @return a value between {@link AppInfo#getMinCarAppApiLevel()} and
* {@link AppInfo#getLatestCarAppApiLevel()}. In case of incompatibility, the host will
* disconnect from the service before completing the handshake
* @throws IllegalStateException if invoked before the connection handshake with the host has
* been completed (for example, before
* {@link Session#onCreateScreen(Intent)})
*/
@CarAppApiLevel
public int getCarAppApiLevel() {
if (mCarAppApiLevel == CarAppApiLevels.UNKNOWN) {
throw new IllegalStateException("Car App API level hasn't been established yet");
}
return mCarAppApiLevel;
}
/** @hide */
@RestrictTo(LIBRARY_GROUP) // Restrict to testing library
@SuppressWarnings({
"argument.type.incompatible",
"method.invocation.invalid"
}) // @UnderInitialization not available with androidx
protected CarContext(@NonNull Lifecycle lifecycle, @NonNull HostDispatcher hostDispatcher) {
super(null);
mHostDispatcher = hostDispatcher;
mAppManager = AppManager.create(this, hostDispatcher, lifecycle);
mNavigationManager = NavigationManager.create(this, hostDispatcher, lifecycle);
mScreenManager = ScreenManager.create(this, lifecycle);
mOnBackPressedDispatcher =
new OnBackPressedDispatcher(() -> getCarService(ScreenManager.class).pop());
LifecycleObserver observer = new DefaultLifecycleObserver() {
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
hostDispatcher.resetHosts();
owner.getLifecycle().removeObserver(this);
}
};
lifecycle.addObserver(observer);
}
}