/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 androidx.car.app.utils.LogTags.TAG;
import static androidx.car.app.utils.RemoteUtils.dispatchCallFromHost;
import static java.util.Objects.requireNonNull;
import android.content.Intent;
import android.content.res.Configuration;
import android.os.Binder;
import android.util.Log;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.car.app.navigation.NavigationManager;
import androidx.car.app.serialization.Bundleable;
import androidx.car.app.serialization.BundlerException;
import androidx.car.app.utils.RemoteUtils;
import androidx.car.app.utils.ThreadUtils;
import androidx.car.app.validation.HostValidator;
import androidx.car.app.versioning.CarAppApiLevels;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleRegistry;
import java.security.InvalidParameterException;
/** Implementation of the binder {@link ICarApp}. */
final class CarAppBinder extends ICarApp.Stub {
private final SessionInfo mCurrentSessionInfo;
@Nullable
private CarAppService mService;
@Nullable
private Session mCurrentSession;
@Nullable
private HostValidator mHostValidator;
@Nullable
private HandshakeInfo mHandshakeInfo;
/**
* Creates a new {@link CarAppBinder} instance for a {@link SessionInfo}. Once the Host
* requests {@link #onAppCreate(ICarHost, Intent, Configuration, IOnDoneCallback)}, the
* {@link Session} will be created.
*/
CarAppBinder(@NonNull CarAppService service, @NonNull SessionInfo sessionInfo) {
mService = service;
mCurrentSessionInfo = sessionInfo;
}
/**
* Explicitly mark the binder to be destroyed and remove the reference to the
* {@link CarAppService}, and any subsequent call from the host after this would be
* considered invalid and throws an exception.
*
* <p>This is needed because the binder object can outlive the service and will not be
* garbage collected until the car host cleans up its side of the binder reference,
* causing a leak. See https://github.com/square/leakcanary/issues/1906 for more context
* related to this issue.
*/
void destroy() {
onDestroyLifecycle();
mCurrentSession = null;
mHostValidator = null;
mHandshakeInfo = null;
mService = null;
}
void onDestroyLifecycle() {
Session session = mCurrentSession;
if (session != null) {
session.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
}
mCurrentSession = null;
}
// incompatible argument for parameter context of attachBaseContext.
// call to onCreateScreen(android.content.Intent) not allowed on the given receiver.
@SuppressWarnings({
"nullness:argument.type.incompatible",
"nullness:method.invocation.invalid"
})
@Override
public void onAppCreate(
ICarHost carHost,
Intent intent,
Configuration configuration,
IOnDoneCallback callback) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onAppCreate intent: " + intent);
}
dispatchCallFromHost(callback, "onAppCreate", () -> {
CarAppService service = requireNonNull(mService);
Session session = mCurrentSession;
if (session == null
|| session.getLifecycle().getCurrentState() == Lifecycle.State.DESTROYED) {
session = service.onCreateSession(requireNonNull(mCurrentSessionInfo));
mCurrentSession = session;
}
session.configure(service,
requireNonNull(getHandshakeInfo()),
requireNonNull(service.getHostInfo()),
carHost, configuration);
// Whenever the host unbinds, the screens in the stack are destroyed. If
// there is another bind, before the OS has destroyed this Service, then
// the stack will be empty, and we need to treat it as a new instance.
LifecycleRegistry registry = (LifecycleRegistry) session.getLifecycle();
Lifecycle.State state = registry.getCurrentState();
int screenStackSize = session.getCarContext().getCarService(
ScreenManager.class).getScreenStack().size();
if (!state.isAtLeast(Lifecycle.State.CREATED) || screenStackSize < 1) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onAppCreate the app was not yet created or the "
+ "screen stack was empty state: "
+ registry.getCurrentState()
+ ", stack size: " + screenStackSize);
}
session.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);
session.getCarContext().getCarService(ScreenManager.class).push(
session.onCreateScreen(intent));
} else {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onAppCreate the app was already created");
}
onNewIntentInternal(session, intent);
}
return null;
});
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onAppCreate completed");
}
}
@Override
public void onAppStart(IOnDoneCallback callback) {
dispatchCallFromHost(
getCurrentLifecycle(), callback,
"onAppStart", () -> {
requireNonNull(mCurrentSession).handleLifecycleEvent(Lifecycle.Event.ON_START);
return null;
});
}
@Override
public void onAppResume(IOnDoneCallback callback) {
dispatchCallFromHost(
getCurrentLifecycle(), callback,
"onAppResume", () -> {
requireNonNull(mCurrentSession).handleLifecycleEvent(Lifecycle.Event.ON_RESUME);
return null;
});
}
@Override
public void onAppPause(IOnDoneCallback callback) {
dispatchCallFromHost(
getCurrentLifecycle(), callback, "onAppPause",
() -> {
requireNonNull(mCurrentSession).handleLifecycleEvent(Lifecycle.Event.ON_PAUSE);
return null;
});
}
@Override
public void onAppStop(IOnDoneCallback callback) {
dispatchCallFromHost(
getCurrentLifecycle(), callback, "onAppStop",
() -> {
requireNonNull(mCurrentSession).handleLifecycleEvent(Lifecycle.Event.ON_STOP);
return null;
});
}
@Override
public void onNewIntent(Intent intent, IOnDoneCallback callback) {
dispatchCallFromHost(
getCurrentLifecycle(),
callback,
"onNewIntent",
() -> {
onNewIntentInternal(requireNonNull(mCurrentSession), intent);
return null;
});
}
@Override
public void onConfigurationChanged(Configuration configuration,
IOnDoneCallback callback) {
dispatchCallFromHost(
getCurrentLifecycle(),
callback,
"onConfigurationChanged",
() -> {
onConfigurationChangedInternal(requireNonNull(mCurrentSession),
configuration);
return null;
});
}
@Override
public void getManager(@CarContext.CarServiceType @NonNull String type,
IOnDoneCallback callback) {
ThreadUtils.runOnMain(() -> {
Session session = requireNonNull(mCurrentSession);
switch (type) {
case CarContext.APP_SERVICE:
RemoteUtils.sendSuccessResponseToHost(
callback,
"getManager",
session.getCarContext().getCarService(
AppManager.class).getIInterface());
return;
case CarContext.NAVIGATION_SERVICE:
RemoteUtils.sendSuccessResponseToHost(
callback,
"getManager",
session.getCarContext().getCarService(
NavigationManager.class).getIInterface());
return;
default:
Log.e(TAG, type + "%s is not a valid manager");
RemoteUtils.sendFailureResponseToHost(callback, "getManager",
new InvalidParameterException(
type + " is not a valid manager type"));
}
});
}
@Override
public void getAppInfo(IOnDoneCallback callback) {
try {
CarAppService service = requireNonNull(mService);
RemoteUtils.sendSuccessResponseToHost(
callback, "getAppInfo", service.getAppInfo());
} catch (IllegalArgumentException e) {
// getAppInfo() could fail with the specified API version is invalid.
RemoteUtils.sendFailureResponseToHost(callback, "getAppInfo", e);
}
}
@Override
public void onHandshakeCompleted(Bundleable handshakeInfo,
IOnDoneCallback callback) {
CarAppService service = requireNonNull(mService);
try {
HandshakeInfo deserializedHandshakeInfo =
(HandshakeInfo) handshakeInfo.get();
String packageName = deserializedHandshakeInfo.getHostPackageName();
int uid = Binder.getCallingUid();
HostInfo hostInfo = new HostInfo(packageName, uid);
if (!getHostValidator().isValidHost(hostInfo)) {
RemoteUtils.sendFailureResponseToHost(callback, "onHandshakeCompleted",
new IllegalArgumentException("Unknown host '"
+ packageName + "', uid:" + uid));
return;
}
AppInfo appInfo = service.getAppInfo();
int appMinApiLevel = appInfo.getMinCarAppApiLevel();
int appMaxApiLevel = appInfo.getLatestCarAppApiLevel();
int hostApiLevel = deserializedHandshakeInfo.getHostCarAppApiLevel();
if (appMinApiLevel > hostApiLevel) {
RemoteUtils.sendFailureResponseToHost(callback, "onHandshakeCompleted",
new IllegalArgumentException(
"Host API level (" + hostApiLevel + ") is "
+ "less than the app's min API level ("
+ appMinApiLevel + ")"));
return;
}
if (appMaxApiLevel < hostApiLevel) {
RemoteUtils.sendFailureResponseToHost(callback, "onHandshakeCompleted",
new IllegalArgumentException(
"Host API level (" + hostApiLevel + ") is "
+ "greater than the app's max API level ("
+ appMaxApiLevel + ")"));
return;
}
service.setHostInfo(hostInfo);
mHandshakeInfo = deserializedHandshakeInfo;
RemoteUtils.sendSuccessResponseToHost(callback, "onHandshakeCompleted",
null);
} catch (BundlerException | IllegalArgumentException e) {
service.setHostInfo(null);
RemoteUtils.sendFailureResponseToHost(callback, "onHandshakeCompleted", e);
}
}
@Nullable
private Lifecycle getCurrentLifecycle() {
return mCurrentSession == null ? null : mCurrentSession.getLifecycle();
}
private HostValidator getHostValidator() {
if (mHostValidator == null) {
mHostValidator = requireNonNull(mService).createHostValidator();
}
return mHostValidator;
}
// call to onNewIntent(android.content.Intent) not allowed on the given receiver.
@SuppressWarnings("nullness:method.invocation.invalid")
@MainThread
private void onNewIntentInternal(Session session, Intent intent) {
ThreadUtils.checkMainThread();
session.onNewIntent(intent);
}
// call to onCarConfigurationChanged(android.content.res.Configuration) not
// allowed on the given receiver.
@SuppressWarnings("nullness:method.invocation.invalid")
@MainThread
private void onConfigurationChangedInternal(Session session,
Configuration configuration) {
ThreadUtils.checkMainThread();
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "onCarConfigurationChanged configuration: " + configuration);
}
session.onCarConfigurationChangedInternal(configuration);
}
void onAutoDriveEnabled() {
Session session = mCurrentSession;
if (session != null) {
session.getCarContext().getCarService(
NavigationManager.class).onAutoDriveEnabled();
}
}
/**
* Used by tests to verify the different behaviors when the app has different api level than
* the host.
*/
@VisibleForTesting
void setHandshakeInfo(@NonNull HandshakeInfo handshakeInfo) {
int apiLevel = handshakeInfo.getHostCarAppApiLevel();
if (!CarAppApiLevels.isValid(apiLevel)) {
throw new IllegalArgumentException("Invalid Car App API level received: " + apiLevel);
}
mHandshakeInfo = handshakeInfo;
}
@VisibleForTesting
@Nullable
HandshakeInfo getHandshakeInfo() {
return mHandshakeInfo;
}
@Nullable
Session getCurrentSession() {
return mCurrentSession;
}
@NonNull
SessionInfo getCurrentSessionInfo() {
return mCurrentSessionInfo;
}
}