During the development of modules, libraries, or SDKs for public audiences, companies often face conflicting demands. On one hand, a company may provide a public API accessible to everyone, while on the other hand, it may need to offer a "hidden" API for specific partners or internal departments. The primary objective of the "hidden" API is to ensure that it remains concealed from public view, difficult to access by unauthorized or uncertified developers, while still being available and accessible to trusted partners.
At DT, we sought to find an optimal solution that could meet these requirements. The goal was to balance security and accessibility, save development time, and minimize the impact on support, integration, and release processes. Furthermore, it was important that the solution would not negatively affect future development timelines.
The solution we have implemented is the use of Obfuscated Code as an API. This approach involves embedding a “hidden” API within our SDK, which will be accessible exclusively to our partners. But before we talk about that, first let's explore the alternatives that we also considered and dismissed: separate flavor and service as API.
One possible approach is to create a separate flavor that keeps the necessary API exposed while leaving other parts obfuscated. We could then provide the artifact to our partners. However, the major challenge with this approach is the increased support and development time.
In this scenario, we may need to handle multiple versions of our SDK, which can quickly become a nightmare in terms of the release process and ongoing support. Of course, we would rely on the CI mechanism for deployment, but this still presents significant challenges.
Another, more generic, but also more complex solution would involve developing a service that communicates with the partner's authorized code and provides secure access to the hidden APIs. While this approach offers flexibility, it will require significantly more development, support, and QA effort.
The solution relies on a Gradle plugin that is published in a private Maven repository. This ensures that the plugin is available only to authorized partners, adding an extra layer of security to access the hidden API.
The plugin wraps the selected obfuscated code and generates a standard API within the partner's project. This allows the partner to use the API seamlessly, just as they would with any standard API.
As a result, many of the cons can be viewed as advantages in the long run, providing a solution that aligns with our expectations and helps us achieve our goals. The solution is generic and ensures that development, QA, and support time remain largely unchanged, while still meeting our requirements.
During the initialization step, the plugin receives the following input parameters:
The plugin reads the mapping file and begins parsing it to map the original target classes to their obfuscated counterparts. This process includes analyzing the methods and members of the classes.
The final step involves creating class wrappers and saving them to the specified output path.
Now let’s go through the main steps of plugin creation.
Init the Gradle plugin project with following commands and let Gradle to deal with it:
mkdir obfuscated-api-plugin
cd obfuscated-api-plugin
obfuscated-api-plugin gradle init \
--use-defaults \
--type kotlin-gradle-plugin \
--dsl kotlin \
--project-name obfuscated-api-plugin \
--package com.example\
--no-split-project \
--no-incubating \
--java-version 21
Create the plugin with extension:
open class ObfusctaedApiPluginExtension {
@Input
var packagename: String = "com.some.packagename"// The wrapped code should be placed under this packagename
@Input
var outputPath: String = "path/where/to/place/generated/code"
@Input
var mappingFilePath: String = "path/to/mapping.txt"
}
class ObfuscatedApiPlugin: Plugin<Project> {
override fun apply(project: Project) {
val extension = project.extensions.create("ObfuscatedApi", ObfusctaedApiPluginExtension::class.java)
project.tasks.register("mapping", ObfuscatedApiTask::class.java) { task ->
task.libname = "the_target_lib"
task.outputPackage = extension.packagename
task.outputPath = extension.outputPath
task.mappingFilePath = extension.mappingFilePath
task.targetClasses = mutableListOf("com.target.ClassA",
"com.target.ClassBListener")
}
}
}
Create the task itself:
abstract class ODTApiMappingTask : DefaultTask() {
@Input
lateinit var libname: String
@Input
lateinit var outputPath: String
@Input
lateinit var outputPackage: String
@Input
lateinit var mappingFilePath: String
@Input
lateinit var targetClasses: MutableList<String>
@TaskAction
fun map() {
val mappingFile = File(mappingFilePath)
val result : Map<String, Container> = Processor.process(mappingFile, searchKeys)
result.values.forEach { cls ->
val builder = WrapperBuilderFactory.createBuilder(outputPath, outputPackage, cls)
builder.build()
}
}
}
It is a simple task which passes the mapping file and list of classes, along with the API to be wrapped, to the processor (parser).
While the implementation of the processor it is out of the post’s scope, we will highlight some important points.
The processor should read and parse the mapping.txt
and return a result. In our case, this is a map with target class as key and container (content) which represents the target class:
Example:
com.example.ClassA -> {Original class name: com.example.ClassA : Obfuscated class name: com.example.b}
methods: {
<init>=(name: <init>,
returnType: void,
argTypes: [android.content.Context, com.example.a,
boolean, com.example.listener.a]),
someMethod=(name: a, returnType: void, argTypes: [java.lang.String,
java.lang.String])
}
It is important to ensure that the argument types in the method are replaced with their obfuscated versions. However, note that SomeListener
is not obfuscated in the method’s arguments:
4:6:void <init>(android.content.Context,com.example.a,boolean,com.example.listeners.SomeListener):33:35 -> <init>
But it was obfuscated below in mapping.txt
:
com.example.listeners.SomeListener -> com.example.listeners.a:
The wrapper builder is the mechanism which creates the wrappers for obfuscated classes under passed path and package name provided to plugin via plugin extension. Different wrappers are used for classes and listeners (interfaces).
The class wrapper generates the class which will contain the instance of the wrapped class as instance.
Example:
package com.some.packagename;
public class ClassA {
private com.example.b mTarget; //original ClassA after obfuscation
public Class(android.content.Context arg1, boolean arg2, com.example.listeners.a arg3) {
mTarget = new com.example.b(arg1, arg2, arg3);
}
public void someMethod(java.lang.String arg1, java.lang.String arg2) {
mTarget.a(arg1, arg2);
}
}
It is better to provide to the wrapper the class and method names that are the same as original names, as this will enhance usability for developers working with this API.
The listener wrapper generates abstract classes and adds abstract methods which should be called by implemented methods.
Example:
package com.some.packagename;
public abstract class MyListener implements com.example.listeners.a {
public abstract void doOnSomeAction(android.content.ComponentName arg0, android.os.IBinder arg1);
@Override
public void b(android.content.ComponentName arg0, android.os.IBinder arg1) {
doOnSomeAction(arg0, arg1);
}
}
In these examples, we can observe how an obfuscated class or listener is used as an argument in a method of another obfuscated class. This is why, during the implementation of the parser, special attention must be given to handling this case, as the mapping file contains the argument in its non-obfuscated form.
The abstract class wrapper uses the combination of both solutions for class and interface wrappers.
Next, the plugin must be prepared to be published. In the example below the plugin is being published to local Maven.
Modify plugin’s build.gradle.kts
and define the plugin details:
version = "0.0.1"
group = "com.exmaple"
gradlePlugin {
val obfusacatedApi by plugins.creating {
id = "com.example"
implementationClass = "com.example.ObfusctaedApiPlugin"
version = "0.0.1"
description = "Plugin's Description"
displayName = "Plugin's Display Name"
}
}
Run the next command to publish to local Maven:
./gradlew publishToMavenLocal
Step 5: Apply the Plugin in the ProjectAfter the plugin was published, apply it in the project which should work with the obfuscated code. In the settings.gradle
, add mavenLocal()
to the repositories inside pluginManagement:
pluginManagement {
repositories {
mavenLocal()
//...
mavenCentral()
gradlePluginPortal()
}
}
Define plugin dependency by adding the next lines in the libs.versions.toml
:
[versions]
...
oapi = "0.0.1"
[libraries]
...
[plugins]
...
oapi-android = { id = "com.example", version.ref = "oapi"}
Apply the plugin in build.gradle.kts
:
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.oapi.android) apply false
id ("org.jetbrains.gradle.plugin.idea-ext") version "1.1.8"
}
And finally configure the plugin inside the build.gradle.kts
:
ObfuscatedApi {
outputPath = Paths.get(projectDir.path,
"src/main/kotlin").absolutePathString()
packagename = "com.some.packagename"
mappingFilePath = "/path/to/mapping/file"
}
Tip: To stay up-to-date and automatically react to each update of the plugin or the wrapped library, the plugin can be executed after the Gradle sync task completes. To do so, in the Gradle tab (in Android Studio or IntelliJ Idea, right-click on plugin’s task and select “Execute After Sync”:
In conclusion, we successfully met all our requirements by implementing an obfuscated code as an API solution. The Obfuscated Code as an API has proven to be the optimal balance between maintenance and development efforts. Moreover, it allows us to make internal changes within the obfuscated code while generating the necessary code through the plugin, all without requiring any modifications to the API layer for our partners.