1. Introduction

Hot repair has been a hot topic in recent years. There are roughly two mainstream solutions, one is the dex file replacement of wechat Tinker, the other is the method replacement of Ali Native layer. Here’s how Tinker works in general.

2. Class loading mechanism

Before introducing the Tinker principle, let’s review the class loading mechanism.

The compiled class file needs to be loaded to the virtual machine before it is executed. This process is done by using the ClassLoader.

Parental delegation model:

  • 1. When a class is loaded, the class loader does not load it immediately. It delegates the load to its parent class
  • 2. If the parent class still has a parent loader, delegate further up to the topmost class loader
  • 3. If the parent class loader can complete the loading task, return successfully, otherwise delegate to the subclass loader
  • 4. Throw ClassNotFoundException if both failed

Functions: 1. Avoid reloading classes. For example, if you have two class loaders and they both want to load the same class, if you load your own class instead of the delegate, you will repeatedly load the class into the method area. 2. Prevent core classes from being modified. For example, if we were customizing a java.lang.String class, we would get an error because String is a class in the java.lang package and should be loaded by the boot class loader.

The JVM doesn’t load all classes at first, it tells the classloader to load them when you need them.

3. The Android class loading

When we create a new class, first the Android virtual machine (Dalvik/ART VIRTUAL machine) uses the ClassLoader to load the dex file into memory. ClassLoader in Android is mainly PathClassLoader and DexClassLoader, both of which inherit from BaseDexClassLoader. They can both be understood as application class loaders.

The difference between PathClassLoader and DexClassLoader:

  • PathClassLoader specifies only the path for loading apK packages, but not the path for decompressing dex files. The path is written dead in /data/dalvik-cache/. Therefore, it can only be used to load the installed APK.

  • DexClassLoader can specify the apK package path and dex file decompression path (load jar, APK, dex files).

When ClassLoader loads a class, its findClass method is called to find the class. FindClass method for BaseDexClassLoader

public class BaseDexClassLoader extends ClassLoader {...@UnsupportedAppUsage
    private finalDexPathList pathList; .@Override
    protectedClass<? > findClass(String name)throws ClassNotFoundException {
        // First check whether the class exists in shared libraries.
        if(sharedLibraryLoaders ! =null) {
            for (ClassLoader loader : sharedLibraryLoaders) {
                try {
                    return loader.loadClass(name);
                } catch (ClassNotFoundException ignored) {
                }
            }
        }
        // Call pathList.findClass to find the class. Null throws an error.
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException(
                    "Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        returnc; }}Copy the code

Let’s look at the findClass implementation of DexPathList:

 public DexPathList(ClassLoader definingContext, String librarySearchPath) {.../** * List of dex/resource (class path) elements
    @UnsupportedAppUsage
    privateElement[] dexElements; .publicClass<? > findClass(String name, List<Throwable> suppressed) {// Iterate through the Element array to find the corresponding class and return it immediately
        for(Element element : dexElements) { Class<? > clazz = element.findClass(name, definingContext, suppressed);if(clazz ! =null) {
                returnclazz; }}if(dexElementsSuppressedExceptions ! =null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null; }... }Copy the code

4. Tinker principle

  • 1. Use DexClassLoader to load the dex file of the patch package
  • 2. Obtain the pathList of the DexClassLoader class by reflection, and obtain the dexElements array by reflection.
  • 3. Get the PathClassLoader that loads the application class, and also get its dexElements array by reflection.
  • 4. Merge two dexElements arrays and put the dex file of the patch package first.

According to the class loading mechanism, a class is only loaded once. In the dexpathList. findClass method, the dex file of the patch is put first, so that the bug fixing class will be loaded first, while the original bug class will not be loaded. The function of replacing bug classes has been achieved (the fix class name and the package name in the patch package must be the same as the bug class)

  • 5. Again through reflection will be assigned to the combined dexElements array PathClassLoader. DexElements properties.

When classes are loaded, the Dalvik/ART virtual machine uses the PathClassLoader to find classes in the installed APK file.

Ok, so the replacement is successful, restart the App, then call the original bug class, will use the fix class in the patch pack first. Why restart: Because of the parental delegation model, a class is only loaded once by the ClassLoader, and the loaded classes cannot be unloaded.

Code implementation

Next up, we’re gonna masturbate a beggar’s Tinker. First, let’s write a bug class.

package com.baima.plugin;

class BugClass {
    public String getTitle(a){
        return "This is a Bug."; }}Copy the code

Next we create a new Module to generate the patch package APK.

Create a bug fixing class with the same package name.

package com.baima.plugin;

class BugClass {
    public String getTitle(a){
        return "Repair successful"; }}Copy the code

Generate patch APK for users to download the patch pack. The next step is to load the APK file and replace it.


    public void loadDexAndInject(Context appContext, String dexPath, String dexOptPath) {

        try {
            // Loader to load the application dex
            PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
            //dexPath Path of the patch dex file
            //dexOptPath Indicates the path where the dex file is stored
            DexClassLoader dexClassLoader = new DexClassLoader(dexPath, dexOptPath, null, pathLoader);
            // Use reflection to get the pathList properties of DexClassLoader and PathClassLoader
            Object dexPathList = getPathList(dexClassLoader);
            Object pathPathList = getPathList(pathLoader);
            // Also use reflection to get the dexElements attribute of DexClassLoader and PathClassLoader
            Object leftDexElements = getDexElements(dexPathList);
            Object rightDexElements = getDexElements(pathPathList);
            // Merge two arrays with the dex file in front of the array
            Object dexElements = combineArray(leftDexElements, rightDexElements);
            Reflection assigns the merged array to pathList.dexElements of the PathClassLoaderObject pathList = getPathList(pathLoader); Class<? > pathClazz = pathList.getClass(); Field declaredField = pathClazz.getDeclaredField("dexElements"); DeclaredField. Set watch, ccessible (true);
            declaredField.set(pathList, dexElements);
        } catch(Exception e) { e.printStackTrace(); }}private static Object getPathList(Object classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException { Class<? > cl = Class.forName("dalvik.system.BaseDexClassLoader");
        Field field = cl.getDeclaredField("pathList");
        field.setAccessible(true);
        return field.get(classLoader);
    }


    private static Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException { Class<? > cl = pathList.getClass(); Field field = cl.getDeclaredField("dexElements");
        field.setAccessible(true);
        return field.get(pathList);
    }

    private static Object combineArray(Object arrayLeft, Object arrayRight) { Class<? > clazz = arrayLeft.getClass().getComponentType();int i = Array.getLength(arrayLeft);
        int j = Array.getLength(arrayRight);
        int k = i + j;
        Object result = Array.newInstance(clazz, k);// Create a new array of type clazz and length k
        System.arraycopy(arrayLeft, 0, result, 0, i);
        System.arraycopy(arrayRight, 0, result, i, j);
        return result;
    }

Copy the code

Ok, Tinker beggar version is finished. First check whether there is a patch in Splash interface when using it, and then replace it. Then you will find that the bug class has been replaced by the repair class in the patch.

5. The plugin

Plug-in development pattern, packaging is one host APK + multiple plug-in APKs. Componentized development mode, packaging is an APK, which is divided into multiple modules.

Advantages:

  • The main APK package installed will be much smaller
  • Business functionality extensions are provided to developers without requiring user updates
  • When bugs occur in functionality in non-main APK packages, they can be fixed in time
  • Users do not need the function, completely will not appear in the system, reduce the burden of equipment

Knowledge to master:

  • 1. Class loading mechanism
  • 2. Startup process of the four components
  • 3.AIDL, Binder mechanism
  • Hook, reflex, agent

5.1 Activity Startup Process

This is the normal Activity start process. The difference between the root Activity start process and the normal Activity start process is that the Application Thread is not created.

Startup process:

  • An Activity in an application process requests AMS to create a normal Activity
  • AMS manages the lifecycle tube and stack of the Activty, validates activities, and so on
  • If the Activity meets AMS’s verification, AMS asks the ActivityThread in the application process to create and start a normal Activity

Cross-process communication between them is enabled by Binder.

5.2 Principle of plug-in

With the hotfix described above, we have a way to load classes in the apk plugin, but we have no way to start an Activity in the plugin, because to start an Activity, it must be registered in androidmanifest.xml.

This paper introduces a mainstream implementation of plug-in -Hook technology.

  • 1. Host App reserves pit occupying Activity
  • 2. Use classLoader to load the dex file to the memory
  • 3. Use spotty Activity first to bypass AMS validation, and then replace the spotty Activity with the plug-in Activity.

Steps 1 and 2 will not be described here, and 2 is the thermal repair technology mentioned above.

5.2.1 Bypass authentication

AMS is in the SystemServer process, we can not directly modify, only in the application process. Introduces a class, IActivityManager, that communicates with AMS of SystemServer processes through AIDL (with a Binder mechanism internally). So IActivityManager fits well as a hook point.

The Activity when they start calling IActivityManager. StartActivity method to AMS start request, the method parameter contains an Intent object, it is meant to start the Intent of the Activity. We can trick AMS into circumventing validation by dynamically proxying the startActivity method of IActivityManager with an Intent that sabotages the Activity and passing the original Intent as a parameter.

public class IActivityManagerProxy implements InvocationHandler {
    private Object mActivityManager;
    private static final String TAG = "IActivityManagerProxy";
    public IActivityManagerProxy(Object activityManager) {
        this.mActivityManager = activityManager;
    }
    @Override
    public Object invoke(Object o, Method method, Object[] args) throws Throwable {
        if ("startActivity".equals(method.getName())) {
            Intent intent = null;
            int index = 0;
            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof Intent) {
                    index = i;
                    break;
                }
            }
            intent = (Intent) args[index];
            Intent subIntent = new Intent();
            String packageName = "com.example.pluginactivity";
            subIntent.setClassName(packageName,packageName+".StubActivity");
            subIntent.putExtra(HookHelper.TARGET_INTENT, intent);
            args[index] = subIntent;
        }
        returnmethod.invoke(mActivityManager, args); }}Copy the code

Next, by reflection, we replace the IActivityManager in ActivityManager with our proxy object.

  public void hookAMS(a) {
        try {
            Object defaultSingleton = null;
            if (Build.VERSION.SDK_INT >= 26) { Class<? > activityManagerClazz = Class.forName("android.app.ActivityManager");
                defaultSingleton = FieldUtil.getObjectField(activityManagerClazz, null."IActivityManagerSingleton");
            } else{ Class<? > activityManagerNativeClazz = Class.forName("android.app.ActivityManagerNative");
                defaultSingleton = FieldUtil.getObjectField(activityManagerNativeClazz, null."gDefault"); } Class<? > singletonClazz = Class.forName("android.util.Singleton");
            Field mInstanceField = FieldUtil.getField(singletonClazz, "mInstance"); Object iActivityManager = mInstanceField.get(defaultSingleton); Class<? > iActivityManagerClazz = Class.forName("android.app.IActivityManager");
            Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), newClass<? >[]{iActivityManagerClazz},new IActivityManagerProxy(iActivityManager));
            mInstanceField.set(defaultSingleton, proxy);
        } catch(Exception e) { e.printStackTrace(); }}Copy the code

Note: Here to obtain IActivityManager instances will vary according to the Android version, specific access methods need to see the source code to understand. The code here, Android 8.0, works.

5.2.2 Restoring the Plug-in Activity

ActivityThread starts the Activity as follows:

Class H is an inner class of the ActivityThread that starts the Activity in the main thread and inherits from the Handler.

private class H extends Handler {
public static final int LAUNCH_ACTIVITY         = 100;
public static final int PAUSE_ACTIVITY          = 101; .public void handleMessage(Message msg) {
            if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
            switch (msg.what) {
                case LAUNCH_ACTIVITY: {
                    Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
                    final ActivityClientRecord r = (ActivityClientRecord) msg.obj;

                    r.packageInfo = getPackageInfoNoCheck(
                            r.activityInfo.applicationInfo, r.compatInfo);
                    handleLaunchActivity(r, null."LAUNCH_ACTIVITY");
                    Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
                } break; . }... }Copy the code

The handleMessage method overridden in H handles LAUNCH_ACTIVITY messages and eventually calls the Activity’s onCreate method. So where do I make the substitution? Handler’s dispatchMessage method:

public void dispatchMessage(Message msg) {
       if(msg.callback ! =null) {
           handleCallback(msg);
       } else {
           if(mCallback ! =null) {
               if (mCallback.handleMessage(msg)) {
                   return; } } handleMessage(msg); }}Copy the code

Handler’s dispatchMessage is used to process messages. If the Handler’s mCallback type is not null, the handleMessage method of the mCallback is executed. Therefore, mCallback can be used as a Hook point, and we can replace mCallback with a custom Callback, as shown below.

public class HCallback implements Handler.Callback{
    public static final int LAUNCH_ACTIVITY = 100;
    Handler mHandler;
    public HCallback(Handler handler) {
        mHandler = handler;
    }
    @Override
    public boolean handleMessage(Message msg) {
        if (msg.what == LAUNCH_ACTIVITY) {
            Object r = msg.obj;
            try {
                // Get the Intent in the message (launching the Intent that spoils the Activity)
                Intent intent = (Intent) FieldUtil.getField(r.getClass(), r, "intent");
                // Get the previously saved Intent(the Intent that started the plug-in Activity)
                Intent target = intent.getParcelableExtra(HookHelper.TARGET_INTENT);
                // Replace the Intent that spoils the Activity with the Intent that inserts the Activity
                intent.setComponent(target.getComponent());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        mHandler.handleMessage(msg);
        return true; }}Copy the code

The final step is to use reflection to our custom callBack Settings to ActivityThread. SCurrentActivityThread. MH. MCallback.


    public void hookHandler(a) {
        try{ Class<? > activityThreadClass = Class.forName("android.app.ActivityThread");
            Object currentActivityThread = FieldUtil.getObjectField(activityThreadClass, null."sCurrentActivityThread");
            Field mHField = FieldUtil.getField(activityThreadClass, "mH");
            Handler mH = (Handler) mHField.get(currentActivityThread);
            FieldUtil.setObjectField(Handler.class, mH, "mCallback".new HCallback(mH));
        } catch(Exception e) { e.printStackTrace(); }}Copy the code

In fact, to start an Activity at this stage, a complete Activity should also require a layout file, and our host APP will not contain resources for the plug-in.

2.3 Loading Plug-in Resources

2.3.1 Resources&AssetManager

Resources in Android can be roughly divided into two types: one is the compilable resource files existing in the RES directory, such as Anim and String, and the other is the original resource files stored in the assets directory. Since these files are not compiled by Apk, they cannot be accessed by id, and certainly not by absolute path. So the Android system lets us get the AssetManager through the getAssets method of Resources and use the AssetManager to access these files.

Resource getString, getText, etc., are all done by calling AssetManager’s private methods. Arsc (the file generated during the AAPT tool packaging process) converts the ID to the name of the Resource file, which is then loaded by AssetManager.

One of the most important methods in AssetManager is the addAssetPath(String Path) method. When the App starts, it will pass in the path of the current APK, and then the AssetManager can access all the resources in the path of the host APK. We can hook the plugin’s path in and the resulting AssetManager can access all the resources of both the host and the plugin.

 public void hookAssets(Activity activity,String dexPath){
        try {
            AssetManager assetManager = activity.getResources().getAssets();
            Method addAssetPath = assetManager.getClass().getMethod("addAssetPath",String.class);
            addAssetPath.invoke(assetManager,dexPath);
            Resources mResources = new Resources(assetManager, activity.getResources().getDisplayMetrics(), activity.getResources().getConfiguration());
            // Next we need to replace the host's existing Resources with the Resources we generated above.
            FieldUtil.setObjectField(ContextWrapper.class,activity.getResources(),"mResources",mResources);
            
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        } catch(Exception e) { e.printStackTrace(); }}Copy the code
2.3.2 id conflict

A new problem arises. The host APK and the plug-in APK are two different APKs, and both generate their own resources.arsc at compile time. That is, they are two separate compilation processes. The resource IDS in their resources.arSC must be the same. The new Resources we created above have duplicate resource ids, so using the resource ID to fetch the resource at run time will cause an error.

How to resolve the resource Id conflict problem? Here’s a look at the solution used by VirtualApk.

The product of modifying AAPT. That is to rearrange the resources of plug-in Apk, arrange the ID and update the R file after compilation

VirtualApkhook ProcessAndroidResourcestask. This task is used to compile Android resources. VirtualApk takes the output of this task and does the following:

  • 1. Collect all the resources in the plug-in from the compiled R.txt file
  • 2. Collect all resources in the host APK based on the compiled R.txt file
  • 3. Filter plugin resources: Filter out resources that already exist in the host
  • 4. Reset the resource ID of the plug-in resource
  • 5. Delete the previously filtered resources from the plug-in resources directory
  • 6. Rearrange the plug-in resource ID in the resources. Arsc file to the newly configured resource ID
  • 7. Regenerate the R.Java file

The general principle is this, but how to ensure that the new Id will not be repeated, here is the composition of the resource Id.

PackageId: The first two characters are packageids, which are used as a namespace to distinguish different package Spaces (not different modules). Currently, when compiling an app, there are at least two package Spaces: the Android system resource pack and our own app resource pack. If you look at the R.java file, you can see that some of the files begin with 0x01 and some begin with 0x7f. The resource ID starting with 0x01 is the one already built in the system, and the one starting with 0x7f is the APP resource ID added by ourselves.

Android resources include animator, Anim, Color, Drawable, Layout, string, etc. TypeId is used to distinguish different resource types.

EntryId: An entryId is the order in which each resource appears in the resource type to which it belongs. Note that Entry ids for different types of resources may be the same, but because they are of different types, we can still distinguish them by their resource ids.

Therefore, to avoid conflicts, the resource ID of the plug-in is usually between 0x02 and 0x7e.