| /* |
| * Copyright (C) 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.tools.agent.app.inspection; |
| |
| import static com.android.tools.agent.app.inspection.InspectorContext.CrashListener; |
| import static com.android.tools.agent.app.inspection.NativeTransport.*; |
| |
| import androidx.inspection.ArtTooling; |
| import androidx.inspection.ArtTooling.EntryHook; |
| import androidx.inspection.ArtTooling.ExitHook; |
| import com.android.tools.agent.app.inspection.version.ArtifactCoordinate; |
| import com.android.tools.agent.app.inspection.version.CompatibilityChecker; |
| import com.android.tools.agent.app.inspection.version.CompatibilityCheckerResult; |
| import java.io.File; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.ConcurrentHashMap; |
| import java.util.concurrent.CopyOnWriteArrayList; |
| import java.util.function.Function; |
| |
| /** This service controls all app inspectors */ |
| // Suppress Convert2Lambda: Lambdas may incur penalty hit on older Android devices |
| // Suppress unused: Methods invoked via jni |
| // Suppress rawtypes: Service doesn't care about specific types, works with Objects |
| @SuppressWarnings({"Convert2Lambda", "unused", "rawtypes"}) |
| public class AppInspectionService { |
| |
| private static AppInspectionService sInstance; |
| |
| public static AppInspectionService instance() { |
| if (sInstance == null) { |
| sInstance = createAppInspectionService(); |
| } |
| return sInstance; |
| } |
| |
| // will be passed to jni method to call methods on the instance |
| @SuppressWarnings("FieldCanBeLocal") |
| // currently AppInspectionService is singleton and it is never destroyed, so we don't clean this reference. |
| private final long mNativePtr; |
| |
| private final Map<String, InspectorBridge> mInspectorBridges = new ConcurrentHashMap<>(); |
| |
| private final Map<String, List<HookInfo<ExitHook>>> mExitTransforms = new ConcurrentHashMap<>(); |
| private final Map<String, List<HookInfo<EntryHook>>> mEntryTransforms = |
| new ConcurrentHashMap<>(); |
| |
| private static final String INSPECTOR_ID_MISSING_ERROR = |
| "Argument inspectorId must not be null"; |
| |
| // TODO: b/159250979 |
| // Special work around to support overloads and exit hooks |
| // currently our labels (keys in mExitTransforms) |
| // lose important information about parameters of methods. |
| // This set stores full information about instrumented methods |
| private final Set<String> mExitTransformsFullLabels = |
| Collections.newSetFromMap(new ConcurrentHashMap<>()); |
| |
| private CrashListener mCrashListener = |
| new CrashListener() { |
| @Override |
| public void onInspectorCrashed(String inspectorId, String message) { |
| sendCrashEvent(inspectorId, message); |
| doDispose(inspectorId); |
| } |
| }; |
| |
| private final CompatibilityChecker mCompatibilityChecker = new CompatibilityChecker(); |
| |
| /** |
| * Construct an instance referencing some native (JVMTI) resources. |
| * |
| * <p>A user shouldn't call this directly - instead, call {@link #instance()}, which delegates |
| * work to JNI which ultimately calls this constructor. |
| */ |
| AppInspectionService(long nativePtr) { |
| mNativePtr = nativePtr; |
| } |
| |
| /** |
| * Creates and launches an inspector on device. |
| * |
| * <p>This will respond with error when an inspector with the same ID already exists, when the |
| * dex cannot be located, and when an exception is encountered while loading classes. |
| * |
| * @param inspectorId the unique id of the inspector being launched |
| * @param dexPath the path to the .dex file of the inspector |
| * @param projectName the name of the studio project that is trying to launch the inspector |
| * @param libraryCoordinate represents the targeted library artifact. |
| * @param force if true, create the inspector even if one is already running |
| * @param commandId unique id of this command in the context of app inspection service |
| */ |
| public void createInspector( |
| String inspectorId, |
| String dexPath, |
| ArtifactCoordinate libraryCoordinate, |
| String projectName, |
| boolean force, |
| int commandId) { |
| if (inspectorId == null) { |
| sendCreateInspectorResponseError(commandId, INSPECTOR_ID_MISSING_ERROR); |
| return; |
| } |
| if (mInspectorBridges.containsKey(inspectorId)) { |
| if (!force) { |
| String alreadyLaunchedProjectName = mInspectorBridges.get(inspectorId).getProject(); |
| sendCreateInspectorResponseError( |
| commandId, |
| "Inspector with the given id " |
| + inspectorId |
| + " already exists. It was launched by project: " |
| + alreadyLaunchedProjectName |
| + "\n\nThis could happen if you launched the same inspector from two different projects at the same time, or if a previous run of the current project crashed unexpectedly and didn't shut down properly."); |
| return; |
| } |
| |
| doDispose(inspectorId); |
| } |
| |
| if (!doCheckVersion(commandId, libraryCoordinate)) { |
| return; |
| } |
| |
| if (!new File(dexPath).exists()) { |
| sendCreateInspectorResponseError( |
| commandId, "Failed to find a file with path: " + dexPath); |
| return; |
| } |
| |
| InspectorBridge bridge = InspectorBridge.create(inspectorId, projectName, mCrashListener); |
| mInspectorBridges.put(inspectorId, bridge); |
| bridge.initializeInspector( |
| dexPath, |
| mNativePtr, |
| (error) -> { |
| if (error != null) { |
| mInspectorBridges.remove(inspectorId); |
| sendCreateInspectorResponseError(commandId, error); |
| } else { |
| sendCreateInspectorResponseSuccess(commandId); |
| } |
| }); |
| } |
| |
| public void disposeInspector(String inspectorId, int commandId) { |
| if (inspectorId == null) { |
| sendDisposeInspectorResponseError(commandId, INSPECTOR_ID_MISSING_ERROR); |
| return; |
| } |
| if (!mInspectorBridges.containsKey(inspectorId)) { |
| sendDisposeInspectorResponseError( |
| commandId, "Inspector with id " + inspectorId + " wasn't previously created"); |
| return; |
| } |
| doDispose(inspectorId); |
| sendDisposeInspectorResponseSuccess(commandId); |
| } |
| |
| public void sendCommand(String inspectorId, int commandId, byte[] rawCommand) { |
| if (inspectorId == null) { |
| sendRawResponseError(commandId, INSPECTOR_ID_MISSING_ERROR); |
| return; |
| } |
| InspectorBridge bridge = mInspectorBridges.get(inspectorId); |
| if (bridge == null) { |
| sendRawResponseError( |
| commandId, "Inspector with id " + inspectorId + " wasn't previously created"); |
| return; |
| } |
| bridge.sendCommand(commandId, rawCommand); |
| } |
| |
| public void cancelCommand(int cancelledCommandId) { |
| // broadcast cancellation to every inspector even if only one of handled this command |
| for (InspectorBridge bridge : mInspectorBridges.values()) { |
| bridge.cancelCommand(cancelledCommandId); |
| } |
| } |
| |
| /** |
| * This command allows the IDE to query for the versions of the libraries it wants to inspect |
| * that are present in this app. |
| * |
| * @param commandId the unique commandId associated with this command |
| * @param coordinates the libraries Studio wants version information for |
| */ |
| public void getLibraryCompatibilityInfoCommand( |
| int commandId, ArtifactCoordinate[] coordinates) { |
| List<CompatibilityCheckerResult> results = new ArrayList<>(); |
| for (ArtifactCoordinate coordinate : coordinates) { |
| CompatibilityCheckerResult result = |
| mCompatibilityChecker.checkCompatibility(coordinate); |
| results.add(result); |
| } |
| NativeTransport.sendGetLibraryCompatibilityInfoResponse( |
| commandId, results.toArray(), results.size()); |
| } |
| |
| private void doDispose(String inspectorId) { |
| removeHooks(inspectorId, mEntryTransforms); |
| removeHooks(inspectorId, mExitTransforms); |
| InspectorBridge inspector = mInspectorBridges.remove(inspectorId); |
| if (inspector != null) { |
| inspector.disposeInspector(); |
| } |
| } |
| |
| /** |
| * Checks whether the inspector we are trying to create is compatible with the library. |
| * |
| * <p>This will compare the provided minVersion with the version string located in the version |
| * file in the APK's META-INF directory. |
| * |
| * <p>In the case the provided version targeting information is null, return true because the |
| * inspector is targeting the Android framework. |
| * |
| * <p>Note, this method will send the appropriate response to the command if the check failed in |
| * any way. In other words, callers don't need to send a response if this method returns false. |
| * |
| * @param commandId the id of the command |
| * @param libraryCoordinate represents the minimum supported library artifact. Null if the |
| * inspector is not targeting any particular library. |
| * @return true if check passed. false if check failed for any reason. |
| */ |
| private boolean doCheckVersion(int commandId, ArtifactCoordinate libraryCoordinate) { |
| if (libraryCoordinate == null) { |
| return true; |
| } |
| CompatibilityCheckerResult versionResult = |
| mCompatibilityChecker.checkCompatibility(libraryCoordinate); |
| if (versionResult.status == CompatibilityCheckerResult.Status.INCOMPATIBLE) { |
| sendCreateInspectorResponseVersionIncompatible(commandId, versionResult.message); |
| return false; |
| } else if (versionResult.status == CompatibilityCheckerResult.Status.NOT_FOUND) { |
| sendCreateInspectorResponseLibraryMissing(commandId, versionResult.message); |
| return false; |
| } else if (versionResult.status == CompatibilityCheckerResult.Status.ERROR) { |
| sendCreateInspectorResponseError(commandId, versionResult.message); |
| return false; |
| } else if (versionResult.status == CompatibilityCheckerResult.Status.PROGUARDED) { |
| sendCreateInspectorResponseAppProguarded(commandId, versionResult.message); |
| return false; |
| } |
| return true; |
| } |
| |
| private static String createLabel(Class origin, String method) { |
| if (method.indexOf('(') == -1) { |
| return ""; |
| } |
| return origin.getCanonicalName() + method.substring(0, method.indexOf('(')); |
| } |
| |
| private static String createFullLabel(Class origin, String method) { |
| return origin.getCanonicalName() + method; |
| } |
| |
| public static void addEntryHook( |
| String inspectorId, Class origin, String method, EntryHook hook) { |
| List<HookInfo<EntryHook>> hooks = |
| sInstance.mEntryTransforms.computeIfAbsent( |
| createLabel(origin, method), |
| new Function<String, List<HookInfo<EntryHook>>>() { |
| |
| @Override |
| public List<HookInfo<EntryHook>> apply(String key) { |
| nativeRegisterEntryHook(sInstance.mNativePtr, origin, method); |
| return new CopyOnWriteArrayList<>(); |
| } |
| }); |
| hooks.add(new HookInfo<>(inspectorId, hook)); |
| } |
| |
| public static void addExitHook( |
| String inspectorId, Class origin, String method, ArtTooling.ExitHook<?> hook) { |
| if (sInstance.mExitTransformsFullLabels.add(createFullLabel(origin, method))) { |
| nativeRegisterExitHook(sInstance.mNativePtr, origin, method); |
| } |
| |
| List<HookInfo<ExitHook>> hooks = |
| sInstance.mExitTransforms.computeIfAbsent( |
| createLabel(origin, method), |
| new Function<String, List<HookInfo<ExitHook>>>() { |
| |
| @Override |
| public List<HookInfo<ExitHook>> apply(String key) { |
| return new CopyOnWriteArrayList<>(); |
| } |
| }); |
| hooks.add(new HookInfo<>(inspectorId, hook)); |
| } |
| |
| private static <T> T onExitInternal(T returnObject) { |
| Error error = new Error(); |
| error.fillInStackTrace(); |
| StackTraceElement[] stackTrace = error.getStackTrace(); |
| if (stackTrace.length < 3) { |
| return returnObject; |
| } |
| StackTraceElement element = stackTrace[2]; |
| String label = element.getClassName() + element.getMethodName(); |
| |
| AppInspectionService instance = AppInspectionService.instance(); |
| List<HookInfo<ExitHook>> hooks = instance.mExitTransforms.get(label); |
| if (hooks == null) { |
| return returnObject; |
| } |
| |
| // TODO: b/159250979, we currently do this deduplication, because |
| // hooks for different methods end up at the same place |
| // to avoid calling the same hook twice we add them to a set |
| Set<ExitHook> calledHooks = new HashSet<>(); |
| for (HookInfo<ExitHook> info : hooks) { |
| //noinspection unchecked |
| if (calledHooks.add(info.hook)) { |
| returnObject = (T) info.hook.onExit(returnObject); |
| } |
| } |
| return returnObject; |
| } |
| |
| public static Object onExit(Object returnObject) { |
| return onExitInternal(returnObject); |
| } |
| |
| public static void onExit() { |
| onExitInternal(null); |
| } |
| |
| public static boolean onExit(boolean result) { |
| return onExitInternal(result); |
| } |
| |
| public static byte onExit(byte result) { |
| return onExitInternal(result); |
| } |
| |
| public static char onExit(char result) { |
| return onExitInternal(result); |
| } |
| |
| public static short onExit(short result) { |
| return onExitInternal(result); |
| } |
| |
| public static int onExit(int result) { |
| return onExitInternal(result); |
| } |
| |
| public static float onExit(float result) { |
| return onExitInternal(result); |
| } |
| |
| public static long onExit(long result) { |
| return onExitInternal(result); |
| } |
| |
| public static double onExit(double result) { |
| return onExitInternal(result); |
| } |
| |
| /** |
| * Receives an array where the first parameter is the "this" reference and all remaining |
| * arguments are the function's parameters. |
| * |
| * <p>For example, the function {@code Client#sendMessage(Receiver r, String message)} will |
| * receive the array: [this, r, message] |
| */ |
| public static void onEntry(Object[] thisAndParams) { |
| assert (thisAndParams.length >= 1); // Should always at least contain "this" |
| Error error = new Error(); |
| error.fillInStackTrace(); |
| StackTraceElement[] stackTrace = error.getStackTrace(); |
| if (stackTrace.length < 2) { |
| return; |
| } |
| StackTraceElement element = stackTrace[1]; |
| String label = element.getClassName() + element.getMethodName(); |
| List<HookInfo<EntryHook>> hooks = |
| AppInspectionService.instance().mEntryTransforms.get(label); |
| |
| if (hooks == null) { |
| return; |
| } |
| |
| Object thisObject = thisAndParams[0]; |
| List<Object> params = Collections.emptyList(); |
| if (thisAndParams.length > 1) { |
| params = Arrays.asList(Arrays.copyOfRange(thisAndParams, 1, thisAndParams.length)); |
| } |
| |
| for (HookInfo<EntryHook> info : hooks) { |
| info.hook.onEntry(thisObject, params); |
| } |
| } |
| |
| private static final class HookInfo<T> { |
| private final String inspectorId; |
| private final T hook; |
| |
| HookInfo(String inspectorId, T hook) { |
| this.inspectorId = inspectorId; |
| this.hook = hook; |
| } |
| } |
| |
| private static void removeHooks( |
| String inspectorId, Map<String, ? extends List<? extends HookInfo<?>>> hooks) { |
| for (List<? extends HookInfo<?>> list : hooks.values()) { |
| for (HookInfo<?> info : list) { |
| if (info.inspectorId.equals(inspectorId)) { |
| list.remove(info); |
| } |
| } |
| } |
| } |
| |
| private static native AppInspectionService createAppInspectionService(); |
| |
| private static native void nativeRegisterEntryHook( |
| long servicePtr, Class<?> originClass, String originMethod); |
| |
| private static native void nativeRegisterExitHook( |
| long servicePtr, Class<?> originClass, String originMethod); |
| |
| } |