bytecode instrumentation for the greater good

Much has been written about bytecode. It is everywhere, and no one is surprised by this: it is generated by the compiler, repackaged by the build system, “corrupted” by the obfuscator, and occasionally read by programmers. Naturally, there are many tools for working with bytecode that are used in different areas and on different platforms. Among them are ByteWeaver — a tool for patching bytecode during build, which can be useful for Android developers.

My name is Alexander Asanov. I am an Android developer at OK, Tracer, ByteWeaver. In this article, I will discuss what bytecode is, how and why to work with it, talk about ByteWeaver and show examples of working with bytecode.

What is bytecode

Bytecode is an intermediate representation of Java code that is executed by the Java Virtual Machine (JVM). When compiling a program, the Java compiler converts it into bytecode, which is a set of instructions that the virtual machine can understand and execute. This principle is true not only for Java, but also for many other modern systems, including LLVM.

The algorithm for the appearance and use of bytecode is as follows:

  • The developer writes the source code.

  • Java source code is compiled into bytecode. Depending on the language and platform, these may be, for example, .class, .dex, .ll files.

  • The byte code is converted into machine code. Strategies here can be different: byte code interpretation, just in time, ahead of time.

In the future, we will focus on Android development, which means we are only interested in JVM bytecode and Dalvik.

Byte code is not as complicated as machine code. Here, to understand what “before and after” looks like, using the example of a small class code:

At the same time, in “before and after” the correspondence is partly preserved – for example, there is also a function title, calls and instructions.

Exception handling exists separately from everything else – it is not implemented in the form of instructions, but in the form of method metadata; the virtual machine will take care of intercepting the exception and passing it to the required label.

The situation is similar when working with Dalvik, the environment for running Android operating system components and user applications. However, since Dalvik is different from Java, the bytecode is also different, but only slightly – you can still see functions, calls, and instructions in it.

In fact, what we have already seen is enough to work with ByteWeaver, because it is just [SPOILER ALERT] and allows you to insert calls at the beginning and end of a method or replace some method calls with others.

Why edit bytecode

There are many scenarios where bytecode manipulation is useful. For example, bytecode patching may be needed for:

  • adding logs – for example, to subsequently pass them to logcat, tracer or other log collection systems;

  • adding traces – for example, systrace and through it to the same tracer;

  • adding other monitoring;

  • searching for and sometimes fixing bugs;

  • definitions of living and dead code;

  • “opening the black box” that happens “under the hood” of the application at the code level.

Bytecode manipulation may be needed in different scenarios.

  • Performance optimization. Performance profiling and optimization tools often modify bytecode to inject code to monitor hot sections of code.

  • Testing and debugging. Tools can dynamically insert logging and debugging facilities during program execution.

  • Aspect-oriented programming. Bytecode patching enables cross-cutting concerns such as logging, transaction management, and security checking.

  • Runtime code generation. Some tools can create new classes at runtime based on dynamic conditions. This provides more flexibility and reduces the amount of duplicate code.

But for patching, naturally, you need the appropriate tools.

Tools for working with bytecode

There are several solutions for working with bytecode:

  • ASM is a library that provides an API for manipulating existing bytecode and/or generating new one.

  • Javassist is a framework that essentially hides bytecode manipulation operations. The developer writes code that is translated into bytecode by the library and embedded into existing classes.

  • AspectJ is a Java extension with its own syntax that is designed to extend the Java runtime environment with aspect-oriented programming concepts. AspectJ has a compiler that can run at both compile time and run time.

The nuance is that for the tasks of a large project like OK, each of these tools in their “pure form” is not particularly suitable:

  • ASM is a low-level solution;

  • Javassist – cannot work in Android runtime;

  • AspectJ is a powerful and feature-rich tool, but it can slow down builds and requires a lot of experience.

ByteWeaver and the history of its formation

Understanding the nuances and shortcomings of existing tools for our use cases based on the ASM library, we developed our own solution, which we later called ByteWeaver. But it did not appear in its current form immediately; a whole series of events preceded it.

  • In 1997, the first bytecode in Java appeared.

  • In 2003, ASM appeared, which actually became the industry standard. Even now, most bytecode manipulations are largely based on ASM. The principle of ASM is simple: bytecode is input, bytecode and the visitor pattern, which is great for data transformation, are output. This allows you to work with bytecode as with data.

  • In 2016, we at OK began actively developing and improving the mechanisms for working with logging. Our goal was to achieve a situation in which, for example, in response to the simplest command log x it will be possible to find out what X is and in what units of measurement. The idea was excellent and viable, we even had a well-developed prototype. But due to some internal circumstances, the idea had to be temporarily abandoned.

  • In 2018, we had our first serious developments within the Tracer project. In particular, we implemented AutoTransform, wrote the main transformation core, and instrumented life cycle methods. The solution had many moments written in the code, but the foundation had already been laid.

  • In 2022, the project was separated from Tracer and reworked into ByteWeaver. The updated implementation introduced a new configuration language, separate publishing, new usage scenarios, and more.

  • In 2023, the ByteWeaver core was migrated to the new AGP transformClassesWithand new scenarios also appeared.

  • Now (in 2024) the tool is being further developed, so there are even more scenarios for working with it.

What bytecode can we edit?

The ability to edit the code depends on when the patching is performed. The .java and .kt files with the source code are converted to the .class format at the earliest stages using the compiler. At the same stage, gradle adds dependencies to these .class files. Thus, the files with dependencies are already at the input of ByteWeaver. That is, ByteWeaver also appears at the early stages of the build and converts classes to .class.

Further along the dynamic assembly cycle, processing is performed by a number of mechanisms:

  • Proguard (R8);

  • Dex (R8) (produces .dex files);

  • AGP, which packs files into an archive and adds resources.

Part of the cycle is performed on the app market side (converting .aab to .apk), but for the purposes of this bytecode review it can be omitted.

If we imagine the build process statically, then dexclassloader (a class loader in Android that loads classes from .jar and .apk files containing an entry classes.dex) works with three groups of entities:

  • application classes (application module, library modules);

  • classes from dependencies (direct, transitive);

  • system classes.

At the same time, system classes do not apply to the application's .apk. Accordingly, only application classes and classes from dependencies can be instrumented. It is important that we do not affect the application's resources, but work only with function calls.

Here it is necessary to note the special position of constant values ​​and inline functions – they are “built in” by the compiler, and it is precisely the places where they are built in that need to be patched.

How to edit bytecode: an example of working with ByteWeaver

ByteWeaver is implemented as a plugin for Gradle. To work with it, you need to perform some operations.

  • Connecting the pluginby executing the following command:

  • Configuring the plugin. We indicate what assembly options there are, what tools will be connected:

    In this case, it is necessary to specify what kind of instrumentation will be used. debug, profile And release. It should be noted that the configuration files (and all subsequent commands) are written in the ByteWeaver language.

  • Defining classeswhich we will influence. For example:

    • any class that extends view;

    • any class that implements runnable;

    • any class from the ru.ok.android package with marked annotations.

    In this case we can also use importwhich allows you to avoid duplication.

  • Insert code at the beginning/end. For example, in all methods annotated AutoTraceCompatin any class we set a challenge at the beginning TraceCompat.beginTraceSection(trace)and at the end – TraceCompat.endSection.

  • We replace calls. For example, in the method subscribeActual class SingleFromCallable challenges callable.call()which return Objectwe will replace it with challenges RxNpeChecker.checkCallableCall(self).

Examples of real transformations in production

We actively use bytecode patching in our production environment. Let's look at a few examples to illustrate this.

“Catching” toasts

One use case for ByteWeaver is catching toasts. Toasts are system notifications that are purely informational and do not require any action from the user. One common example of a toast notification is a notification about receiving developer rights.

To catch toasts, in any class and in any method, calls Toast.show() change to ToastWatcher.show(self).

After that we write ToastWatcher with the method show. That is, in the end we do not affect the main functionality, but we additionally hang listener(toast). It is important that this is a static method (@JvmStatic), as well as all the methods we plan to add.

Notification logging

This isn't about push notifications, but about the fact that different libraries can show notifications. We want to track everyone who tries to display something in the Android notification shade – we encountered this problem when there were too many notifications in our app and it started to negatively affect the user experience.

To catch all notifications, we did the following. In any class, calls NotificationManager.notify we replaced it with NotificationsLogger.

Next NotificationsLogger everything is forwarded to LogNotificationsUtilwhich logs functionality without affecting it.

Then LogNotificationsUtildepending on the flag, tracks and collects all information about the notification and its sender.

Search for bugs

Not long ago we encountered the following situation – there is not a single line of our code in Tracer, but it is displayed NullPointerException. Someone returned null to RxJava 3, to which RxJava 3 issued a notification “The callable returned a null value”.

At the same time, it is absolutely unclear which callable returned null when and why – there is no information.

We initially planned to fork RxJava 3, but then decided to use ByteWeaver. When examining the code, we saw that the message “The callable returned a null value” is simply written in the class SingleFromCallable.

To make this message useful and interpretable, we decided to enrich it by adding additional information. To do this, we replaced the calls Callable.call on RxNpeChecker.

RxNpeCheckerin turn, makes a call Callablebut with another Exceptionwhich contains significantly more useful information.

Thanks to this we were able to identify that the null value was returned callable l90.b.

Now we can localize the source of the error without ByteWeaver. To do this, we look at who is l90.band we see that this is some kind of ExternalSyntheticLambda1 V RxApiClient. And in RxApiClient It can be seen that null is returned by one of the API methods.

To find a specific method using Java code, we do additional logging and start adding more information about the API method.

As a result, after simple manipulations, we were able to accurately localize the source of our “problems”:

Parsed api value was null. Request: UserInfoRequest{uids=780917803396}, method: users.getInfo, parser: b80.t@43beec0

This allowed us to fix bugs in a targeted manner without unnecessary risks and global rework.

Thus, we:

  • Caught RxApiClientmethod users.getInfo and three methods from the Friends group at once: friends.getOnlineV2, friends.getOutgoingFriendRequests, friends.invite (all returned null for various reasons). Everything was fixed and covered with checks.

  • Caught and corrected the class LocalPhotoEditorFragment.

We didn't even need to fork RxJava to do this – ByteWeaver was a big help with that.

SysTrace Enrichment for Tracer

When we started collecting traces, we saw that they did not contain enough data and we could not add all of them manually (and it was not very rational). Therefore, we needed automation.

To do this, we did the following. In all classes, methods annotated @AutoTraceCompatwill be covered by traces. In short, we mark the beginning and end of the call, thanks to which we can then see which methods were called and how they worked.

We also cover lifecycle methods in all classes with traces. Activity. Similarly, we cover the life cycle methods in all classes. Fragment. In addition, we cover the following classes with traces:

  • Service;

  • ContentProvider;

  • View;

  • Handler;

  • Handler.Callback;

  • JobIntentService;

  • Runnable.

We also mark method signatures inject(Activity) And inject(Fragment). This is needed for dagger. For all methods we add to the beginning TraceCompat.beginTraceSection(trace)and at the end – TraceCompat.endSection().

Such patching significantly expands the array of collected information and makes it more complete/interpretable. For comparison, it is enough to look at Java Flame Graphs before and after enrichment.

The amount of collected information, as well as details, has increased significantly, and it is visual, interpretable and detailed. This simplifies tracking events and subsequent correction of possible bugs. It is noteworthy that all these details were added using ByteWeaver.

What we did and what we shouldn't do

So we:

  • added logs (logcat, tracer);

  • added tracing (systrace, tracer);

  • opened the black box for testing;

  • searched for and found bugs.

Implementing changes and using ByteWeaver actually allowed us to work with the code transparently and conveniently, quickly identify events and localize sources of errors. It is important that the “price” of such innovations for us was insignificant – the time of building the OK application increased by only 5 seconds, which is quite acceptable on the scale of our product.

However, there are things that we did not do and do not advise others to do:

  • Bug fixes. With ByteWeaver you don't have to fix bugs. It's not obvious, it creates unwanted artifacts in the stacktrace and during debugging, and it increases bus factor risks.

  • Generating “production” code. ByteWeaver is best used for working with “side code”, and it is important not to interfere with its execution. The product code itself and product logic should not be touched – this is fraught with risks and unnecessary difficulties.

Plans for the future

We are not stopping there and plan to actively develop work with bytecode and ByteWeaver.

  • Currently, inserting a call at the beginning of a method only allows you to receive traces, but does not allow you to work with method arguments. We want to come to a situation where by inserting a call at the beginning of a method we will receive arguments and even be able to influence them (read-only/read-write).

  • We also want calls at the end of the method to be able to receive a result or an exception. Ideally, we also want to be able to influence these results.

  • Along with this, we want to implement the ability to replace the entire body of methods (with arguments and results), that is, to get the ability to use replace body.

  • To find methods that shouldn't exist, we plan to add stopship. This way, we want to limit work with functions that contain a bug or are removed, but continue to be called somewhere else.

  • We also want to add a bit of decompilation. For example, so that in response to log(x) receive log("x = $x").

Conclusions based on our experience

Having come a long way working with Android applications, we were able to draw several key conclusions:

  • Sometimes the source code level is not enough to understand what exactly is wrong, why, and from what point. Often you need to “dig deeper”.

  • Knowledge of bytecode is not required, but it helps you find and fix bugs, enable additional monitoring, and implement other scenarios without having to edit the source code.

  • ByteWeaver is a convenient and functional tool for patching bytecode. It can be used in various scenarios, including collecting statistics, finding and fixing bugs, and solving specific problems. It is important that ByteWeaver is already Available in Open Source — you can test the tool and start working with it in your projects right now.

And yes, if you haven't worked with bytecode yet, now is the time to start diving into the topic. It may be difficult, but it will definitely be fun and useful.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *