blob: 83005e32ff270009295d9e101eec3c3e75499a93 [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.customtabs.dynamicmodule;
import android.content.ComponentCallbacks2;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Process;
import android.support.annotation.Nullable;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.task.AsyncTask;
import org.chromium.chrome.browser.ChromeApplication;
import org.chromium.chrome.browser.ChromeFeatureList;
import org.chromium.chrome.browser.customtabs.dynamicmodule.ModuleMetrics.DestructionReason;
import org.chromium.components.crash.CrashKeyIndex;
import org.chromium.components.crash.CrashKeys;
import java.util.ArrayList;
import java.util.List;
/**
* Dynamically loads a module from another apk.
*/
public class ModuleLoader {
private static final String TAG = "ModuleLoader";
/** Specifies the module package name and entry point class name. */
private final ComponentName mComponentName;
private final String mModuleId;
/**
* Tracks the number of usages of the module. If it is no longer used, it may be destroyed, but
* the time of destruction depends on the caching policy.
*/
private int mModuleUseCount;
private boolean mIsModuleLoading;
private final ObserverList<Callback<ModuleEntryPoint>> mCallbacks = new ObserverList<>();
private final List<Bundle> mPendingBundles = new ArrayList<>();
/**
* The timestamp of the moment the module became unused. This is used to determine whether or
* not to continue caching it. A value of -1 indicates there is no usable value.
*/
private long mModuleUnusedTimeMs = -1;
/**
* The name of the experiment parameter for setting the caching time limit.
*/
private static final String MODULE_CACHE_TIME_LIMIT_MS_NAME = "cct_module_cache_time_limit_ms";
/**
* The default time limit for caching an unused module under mild memory pressure, in
* milliseconds.
*/
private static final int MODULE_CACHE_TIME_LIMIT_MS_DEFAULT = 300000; // 5 minutes
@Nullable
private ModuleEntryPoint mModuleEntryPoint;
/**
* Instantiates a new {@link ModuleLoader}.
* @param componentName Specifies the module package name and entry point class name.
*/
public ModuleLoader(ComponentName componentName) {
mComponentName = componentName;
String packageName = componentName.getPackageName();
int versionCode = 0;
String versionName = "";
try {
PackageInfo info = ContextUtils.getApplicationContext()
.getPackageManager()
.getPackageInfo(packageName, 0);
versionCode = info.versionCode;
versionName = info.versionName;
} catch (PackageManager.NameNotFoundException ignored) {
// Ignore the exception. Failure to find the package name will be handled in
// getModuleContext() below.
}
mModuleId = String.format("%s v%s (%s)", packageName, versionCode, versionName);
}
public ComponentName getComponentName() {
return mComponentName;
}
/**
* If the module is not loaded yet, dynamically loads the module entry point class.
*/
public void loadModule() {
if (mIsModuleLoading) return;
// If module has been already loaded all callbacks must be notified synchronously.
// {@see #addCallbackAndIncrementUseCount}
if (mModuleEntryPoint != null) {
assert mCallbacks.isEmpty();
return;
}
Context moduleContext = getModuleContext(mComponentName.getPackageName());
if (moduleContext == null) {
runAndClearCallbacks();
return;
}
ModuleMetrics.registerLifecycleState(ModuleMetrics.LifecycleState.NOT_LOADED);
mIsModuleLoading = true;
new LoadClassTask(moduleContext).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
/**
* Loads the dynamic module if it is not loaded yet,
* and transfers the bundle to it regardless of the previous loaded state.
*/
public void sendBundleToModule(Bundle bundle) {
if (mModuleEntryPoint != null) {
mModuleEntryPoint.onBundleReceived(bundle);
return;
}
mPendingBundles.add(bundle);
loadModule();
}
/**
* Register a callback to receive a {@link ModuleEntryPoint} asynchronously.
* If the module fails to load, the callback will receive null.
* If the module was already loaded and a reference to it is still held,
* the callback will synchronously receive a {@link ModuleEntryPoint}.
*
* Module use count is incremented when a callback notified.
*
* @param callback The callback to receive the result.
*/
public void addCallbackAndIncrementUseCount(Callback<ModuleEntryPoint> callback) {
if (mModuleEntryPoint != null) {
mModuleUseCount++;
mModuleUnusedTimeMs = -1;
ModuleMetrics.recordLoadResult(ModuleMetrics.LoadResult.SUCCESS_CACHED);
callback.onResult(mModuleEntryPoint);
return;
}
mCallbacks.addObserver(callback);
}
public void removeCallbackAndDecrementUseCount(Callback<ModuleEntryPoint> callback) {
boolean isPendingCallback = mCallbacks.removeObserver(callback);
if (mModuleEntryPoint == null || isPendingCallback) return;
mModuleUseCount--;
if (mModuleUseCount == 0) {
mModuleUnusedTimeMs = ModuleMetrics.now();
if (!ChromeFeatureList.isEnabled(ChromeFeatureList.CCT_MODULE_CACHE)) {
destroyModule(DestructionReason.NO_CACHING_UNUSED);
}
}
}
/**
* Destroys the unused cached module (if present) under certain circumstances. If the memory
* signal is considered severe, the module will always be destroyed. If the memory signal is
* considered mild, the module will only be destroyed if the time limit has passed.
* @param level The type of signal as defined in {@link ComponentCallbacks2}.
*/
public void onTrimMemory(int level) {
if (mModuleEntryPoint == null || mModuleUseCount > 0) return;
if (ChromeApplication.isSevereMemorySignal(level)) {
destroyModule(DestructionReason.CACHED_SEVERE_MEMORY_PRESSURE);
} else if (cacheExceededTimeLimit()) {
if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
destroyModule(DestructionReason.CACHED_UI_HIDDEN_TIME_EXCEEDED);
} else {
destroyModule(DestructionReason.CACHED_MILD_MEMORY_PRESSURE_TIME_EXCEEDED);
}
}
}
private boolean cacheExceededTimeLimit() {
if (mModuleUnusedTimeMs == -1) return false;
long limit = ChromeFeatureList.getFieldTrialParamByFeatureAsInt(
ChromeFeatureList.CCT_MODULE_CACHE, MODULE_CACHE_TIME_LIMIT_MS_NAME,
MODULE_CACHE_TIME_LIMIT_MS_DEFAULT);
return ModuleMetrics.now() - mModuleUnusedTimeMs > limit;
}
private void destroyModule(@DestructionReason int reason) {
assert mModuleEntryPoint != null;
ModuleMetrics.recordDestruction(reason);
mModuleEntryPoint.onDestroy();
CrashKeys.getInstance().set(CrashKeyIndex.ACTIVE_DYNAMIC_MODULE, null);
ModuleMetrics.registerLifecycleState(ModuleMetrics.LifecycleState.DESTROYED);
mModuleEntryPoint = null;
mModuleUnusedTimeMs = -1;
}
/**
* Notify all callbacks which are waiting for module loading. Each callback is needed to notify
* only once therefore all callbacks are cleared after call.
*/
private void runAndClearCallbacks() {
assert !mIsModuleLoading;
if (mModuleEntryPoint != null && mCallbacks.size() > 0) {
mModuleUseCount += mCallbacks.size();
mModuleUnusedTimeMs = -1;
}
for (Callback<ModuleEntryPoint> callback: mCallbacks) {
callback.onResult(mModuleEntryPoint);
}
mCallbacks.clear();
}
private void sendAllBundles() {
assert !mIsModuleLoading;
for (Bundle bundle: mPendingBundles) {
mModuleEntryPoint.onBundleReceived(bundle);
}
mPendingBundles.clear();
}
/**
* A task for loading the module entry point class on a background thread.
*/
private class LoadClassTask extends AsyncTask<Class<?>> {
private final Context mModuleContext;
/**
* Constructs the task.
* @param moduleContext The context for the package to load the class from.
*/
LoadClassTask(Context moduleContext) {
mModuleContext = moduleContext;
}
@Override
@Nullable
protected Class<?> doInBackground() {
int oldPriority = Process.getThreadPriority(0);
try {
// We don't want to block the UI thread, but we don't want to be really slow either.
// The AsyncTask class sets the thread priority quite low
// (THREAD_PRIORITY_BACKGROUND) and does not distinguish between user-visible
// user-invisible tasks.
// TODO(crbug.com/863457): Replace this with something like a task trait that
// influences priority once we have a task scheduler in Java.
Process.setThreadPriority(Process.THREAD_PRIORITY_DEFAULT);
long entryPointLoadClassStartTime = ModuleMetrics.now();
Class<?> clazz =
mModuleContext.getClassLoader().loadClass(mComponentName.getClassName());
ModuleMetrics.recordLoadClassTime(entryPointLoadClassStartTime);
return clazz;
} catch (ClassNotFoundException e) {
Log.e(TAG, "Could not find class %s", mComponentName.getClassName(), e);
ModuleMetrics.recordLoadResult(ModuleMetrics.LoadResult.CLASS_NOT_FOUND_EXCEPTION);
} finally {
Process.setThreadPriority(oldPriority);
}
return null;
}
@Override
protected void onPostExecute(@Nullable Class<?> clazz) {
mIsModuleLoading = false;
if (clazz == null) {
runAndClearCallbacks();
return;
}
try {
long entryPointNewInstanceStartTime = ModuleMetrics.now();
IBinder binder = (IBinder) clazz.newInstance();
ModuleMetrics.recordEntryPointNewInstanceTime(entryPointNewInstanceStartTime);
ModuleHostImpl moduleHost =
new ModuleHostImpl(ContextUtils.getApplicationContext(), mModuleContext);
ModuleEntryPoint entryPoint =
new ModuleEntryPoint(IModuleEntryPoint.Stub.asInterface(binder));
if (!isCompatible(moduleHost, entryPoint)) {
Log.w(TAG,
"Incompatible module due to version mismatch: host version %s, "
+ "minimum required host version %s, entry point version %s, "
+ "minimum required entry point version %s.",
moduleHost.getHostVersion(), entryPoint.getMinimumHostVersion(),
entryPoint.getModuleVersion(), moduleHost.getMinimumModuleVersion());
ModuleMetrics.recordLoadResult(ModuleMetrics.LoadResult.INCOMPATIBLE_VERSION);
runAndClearCallbacks();
return;
}
CrashKeys crashKeys = CrashKeys.getInstance();
crashKeys.set(CrashKeyIndex.LOADED_DYNAMIC_MODULE, mModuleId);
crashKeys.set(CrashKeyIndex.ACTIVE_DYNAMIC_MODULE, mModuleId);
ModuleMetrics.registerLifecycleState(ModuleMetrics.LifecycleState.INSTANTIATED);
long entryPointInitStartTime = ModuleMetrics.now();
entryPoint.init(moduleHost);
ModuleMetrics.recordEntryPointInitTime(entryPointInitStartTime);
ModuleMetrics.recordLoadResult(ModuleMetrics.LoadResult.SUCCESS_NEW);
mModuleEntryPoint = entryPoint;
mModuleUnusedTimeMs = ModuleMetrics.now();
runAndClearCallbacks();
sendAllBundles();
return;
} catch (Exception e) {
// No multi-catch below API level 19 for reflection exceptions.
// This catches InstantiationException and IllegalAccessException.
Log.e(TAG, "Could not instantiate class %s", mComponentName.getClassName(), e);
ModuleMetrics.recordLoadResult(ModuleMetrics.LoadResult.INSTANTIATION_EXCEPTION);
}
runAndClearCallbacks();
}
}
@Nullable
private static Context getModuleContext(String packageName) {
try {
// The flags Context.CONTEXT_INCLUDE_CODE and Context.CONTEXT_IGNORE_SECURITY are
// needed to be able to load classes via the classloader of the returned context.
long createPackageContextStartTime = ModuleMetrics.now();
Context moduleContext = ContextUtils.getApplicationContext().createPackageContext(
packageName, Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY);
ModuleMetrics.recordCreatePackageContextTime(createPackageContextStartTime);
return moduleContext;
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Could not create package context for %s", packageName, e);
ModuleMetrics.recordLoadResult(
ModuleMetrics.LoadResult.PACKAGE_NAME_NOT_FOUND_EXCEPTION);
}
return null;
}
private static boolean isCompatible(ModuleHostImpl moduleHost, ModuleEntryPoint entryPoint) {
return entryPoint.getModuleVersion() >= moduleHost.getMinimumModuleVersion()
&& moduleHost.getHostVersion() >= entryPoint.getMinimumHostVersion();
}
@VisibleForTesting
public int getModuleUseCount() {
return mModuleUseCount;
}
}