As an Android code base grows and has increasing code churn, unused resources are very likely to exist. While Android Studio has an option for cleaning up and removing unused resources, there are some reported bugs in this functionality that are still not fixed.
I started searching for another solution for finding and cleaning up unused resources in Android projects. I found this project, which has some Python 2 scripts to directly manipulate resources. Since it hadn’t been updated since 2018, I took the liberty to build something based on it, which fixes some of the issues I ran into while using it.
Introducing resource-cleanup - an open source project to help identify and clean unused resources (in retrospect, I wish I had named it “Mr. Kaplan”). It relies on severalopen sourcetools and heavily relies on the command line to accomplish its role.
The project has a set of shell scripts - one per resource type. It searches for usages of @[resourceType] or R.resourceType references. In the absence of these, it removes the given resource. The shell scripts take a single file and checks for usages accordingly. The documentation gives examples of how to combine this with tools like fd and shell loops.
These scripts have proven invaluable to us at work and helped us reduce our apk size by a sizable amount. Please give it a try!
I gave a talk at The Assembly in Dubai about tips and tricks for productively building mobile applications. In it, I discuss experimentation, catching issues earlier in CI/CD, multiplatform, and various other topics. Here are the slides.
Monitoring Gradle Build Speeds for Smaller Projects
Today, there are manygreatarticles and videos about how to optimize Gradle performance for Android builds. Larger companies watch these metrics closely, since build time translates into money. These twocomics sum up the situation pretty nicely.
I write and maintain several of my own Android applications. This got me to thinking about my own app build times. I started off using the gradle profiler, as recommended by many of the aforementioned articles and videos (this article gives a nice introduction on how to use the gradle-profiler in the light of Android apps).
Having a way to measure is great, but how do I know how my build times are doing over time? I wrote and open sourced profiler-util, a Kotlin app, for just this purpose.
Visualizing Build Performance over Time
After running a gradle-profile, one of the outputs is a benchmark.csv file with the format. The tool I wrote has two modes - the first uploads the results from the benchmark.csv file into a Google Sheets spreadsheet.
What’s nice about using Google Sheets is that we also get graphing out of the box:
Note that the incomplete lines are benchmarks I added later on.
Detecting regressions
The second tool uses the step fitting method that I learned about from this excellent article about benchmarks to figure out which builds caused a regression or helped improve the build speeds. Essentially, build metrics from a single profiling run might not be enough to determine whether a change occurred or not, especially due to variations that could occur on the system running the profiling. Using the --iterations flag of gradle-profiler helps.
Note: Today, I use the average of all iteration times for a given scenario as the number I consider. Only looking at the mean causes a loss of information (and assumes the data follows a normal distribution when it doesn’t). For more accuracy, additional signals (such as median and p95) should also be considered.
Instead of using changes in a given value to determine whether or not there is an improvement or regression due to a particular PR, this tool instead uses a windowed approach to check if there is a notable difference in the build performance when a window before the commit is compared to one after the commit.
From this, we can see that the commit in which I updated to use non-transitive R files helped speed up things when adding a resource, and when making an abi change to app. Note that, while the graph shows some other potential improvements, the tool doesn’t list them because the threshold doesn’t consider them as substantial as the other changes reported.
Where to Run Benchmarks
When I began this process, I made an initial mistake, thinking that I could run these tests as part of CI on every pull request or merge of code. Unfortunately, after trying it out, the numbers were all over the place. In retrospect, this makes sense - there are no guarantees about the VMs or machines that a particular provider is using for running these builds, how loaded the physical hardware is, and so on. Moreover, running these profiling builds on CI typically takes forever. The combination of all of these can cause a great deal of variance. It is worth noting, however, that some measures can be taken to make this data more usable - using bare metal instances instead of virtual machines, for example, or running a massive number of builds such that outliers can be smoothed out can both help.
Instead, I settled on running these on my own laptop after making changes that I think would be relevant data points to have. In the future, I could probably rent a dedicated, bare metal server or purchase a dedicated machine and run these tests on a cron, but given that these are personal projects, it’s probably overkill at this point.
Conclusion
In the future, it’d be nice to change this to allow various data export methods - instead of writing to Google Sheets, have an option to write to a database, for example. The Google Sheets integration piece is also interesting, since it can be used for quick visualizations of other pieces of data - apk size and macro/micro benchmark results (though this post from the performance-samples project might be the way to go on this one). Please feel free to share your ideas or feature requests on the GitHub project.
Special thanks to Tony Robalik and Efeturi Money for reviewing this post and giving valuable suggestions.
I wanted to write a short post about how to find the reason for a particular version of a dependency to be selected by Gradle.
Today, while building our app, we started getting this error:
The minCompileSdk (31) specified in a
dependency's AAR metadata (META-INF/com/android/build/gradle/aar-metadata.properties)
is greater than this module's compileSdkVersion (android-30).
Dependency: androidx.core:core:1.7.0-alpha02.
AAR metadata file: /Users/ahmedre/.gradle/caches/transforms-3/3a90b660583f6aafd513bafe95732d86/transformed/core-1.7.0-alpha02/META-INF/com/android/build/gradle/aar-metadata.properties.
The error makes sense, but my question was, “who pulls in androidx.core:core:1.7.0-alpha02 - we shouldn’t be using alpha dependencies at the moment, so where is this coming from?”
The first place to look was through Gradle’s dependency target -
Looking through this, I can see indeed that androidx.core:core is being updated from various other versions to 1.7.0-alpha02, but I don’t know why. I did some searching, and today I learned about the dependencyInsight target. Quoting the Gradle documentation:
The dependencies report provides you with the raw list of dependencies but does not explain why they have been selected or which dependency is responsible for pulling them into the graph.
This is the culprit! The publisher of this dependency seems to have added an androidx.core:core-ktx:+ dependency. I checked and this was indeed the case. Mystery solved!
The next question was, how do we avoid having this happen again? Some more searching lead me to this post in the Gradle documentation, which suggests the usage of the failOnDynamicVersions(). We can also prevent snapshots by doing failOnChangingVersions(). Awesome!
I gave a talk about building a PrayerTimes mobile application for iOS and Android using Kotlin Multiplatform. The talk is mostly a live coding talk in which we write a simple PrayerTimes mobile app from scratch. Under the hood, the app is using a Kotlin Multiplatform port of the BatoulApps Adhan library. In addition to the video, you can see the code on Github here, and the (very short, 10 slide) slide deck here.
Note - A plethora of excellent articles and talks describing how to write an annotation processor exist - consequently, this blog post will not talk about the details on how to build an annotation processor. Instead, it addresses a specific case that I had a much more difficult time finding answers for online and a strategy for solving it.
Scenario
Suppose we are shipping an app with multiple libraries pulled from a company Maven repository. Let’s say we want to write an annotation processor that outputs a list of all classes annotated with a certain annotation. In other words, given:
// in repo1, artifact com.company.app:module1
@CompanyModuleclassFoo : CommonModule
// in repo2, artifact com.company.app:module2
@CompanyModuleclassBaz : CommonModule
// in the app repo
@CompanyModuleclassBin : CommonModule
// we want an implementation of this generated
@CompanyModuleRepositoryinterfaceCompanyRepository {
val modules: List<CommonModule>
}
we want a class in our app module generated that looks like:
Note - Uber has open sourced an annotation processor, Crumb, that handles this case painlessly.
Initial Attempts at a Picasso
Given this problem, let’s write an annotation processor to do this. Let’s take the obvious approach first, one in which we get all classes annotated with @CompanyModuleRepository and all the classes annotated with @CompanyModule. Using this information, we’ll write code to generate the CompanyRepositoryImpl class.
If we do this, we’ll find that the only modules we’ll be able to pick up are the ones in app - so in the above case, the Bin module is the only one that will be added.
Roadblocks and Imperfect Frescos
If we debug our annotation processor code, we’ll find that a line that looks for all the CompanyModules -
val modules = roundEnv.getElementsAnnotatedWith(CompanyModule::class.java)
Only finds the single module, Bin, within our app module. If we add another module in app, it will also be found, but all our modules from our artifacts won’t be found.
Why? When the annotation processor runs, it will run against a particular module. In this case, we’re running the annotation processor against our app module. It will find any annotated classes in app with no problem.
On the other hand, our dependencies, module1 and module2, are coming from a Maven repository as binary dependencies - i.e. they’re precompiled. Consequently, the annotation processor will not run on them at this point, since it’s too late for that. This is true even if the RETENTION on the annotations is properly set to AnnotationRetention.BINARY as it should be in this case. To solve this, we’ll need something to run while module1 and module2 are compiling, and take this result into consideration while compiling app at the end.
Gliding to Completion
After a lot of digging, I found people pointing to Glide as one of the canonical reference annotation processors that people look at while writing their own annotation processors. Reading their code and stepping through it with a sample project, here’s a summary of what Glide does:
For each LibraryGlideModule or GlideExtension (CompanyModule in our example), an Indexer class is generated in a consistent package (irrespective of the aforementioned annotated class’s package) with an @Index annotation and the fully qualified path of the original annotated class. In Glide’s case, all of these generated indices are written to com.bumptech.glide.annotation.compiler. This output generated Indexer class looks something like this:
For each AppGlideModule (Glide restricts these to only 1 - this is our CompanyModuleRepository), the processor looks for all files in the aforementioned directory (com.bumptech.glide.annotation.compiler), filters out only the ones with an @Index annotation, and uses those to generate the list of modules.
val glideGenPackage = processingEnv.elementUtils.getPackageElement(COMPILER_PACKAGE_NAME);
Knowing how Glide does this, we can apply the same strategy to our problem. In our case, we’ll generate an @Index annotated class for each @CompanyModule and we’ll write that to a common directory - com.company.generated.module for example.
Using the beginning example, our modules that will be pushed to Maven will look something like this:
When the annotation processor is run against App, it can then find all files present in com.company.generated.module, check which ones properly have the @Index annotation (and read the full path from them), and use that information to build the list.
One Last Potential Pitfall
Glide’s annotation processor is outputting Java code using JavaPoet. If, instead, we decide to generate Kotlin code using KotlinPoet, there’s one more gotcha we need to look out for.
Consider the case where we have a single module with 1 class - running our code, we might find nothing generated, and re-running it, something might be generated. Huh? Why? The point to remember here is that kapt does not process newly generated Kotlin sources across multiple rounds (see bug here). The workaround for this is to generate our Indexer classes using JavaPoet instead of KotlinPoet (see this KotlinPoet issue for some useful extension functions for making this easier).
Special thanks to Efeturi for reviewing this post.
One of the interesting projects I worked on this year was building Careem’s SuperApp. To do this, we combined Careem’s main two applications, the RideHailing application and the Food deliveries application, into a single app. This may sound simple at first, but it is actually an interesting problem with many layers to unravel.
I gave a talk about this at 360|AnDev this year. You can find the slides here.
update - i did a small presentation about this as part of TouchlabShare - you can watch the video here.
In the past few days, I began looking at multithreading in Kotlin Multiplatform more carefully when I started a new iOS/Android project that I wanted to share business logic for. I realized that, despite understanding the rules of multithreading in Kotlin Native, I didn’t fully grasp the implications thereof. I thought to write this blog post to share what I learned (and as a reference for my future self).
Note - the excellent Practical Kotlin Native Concurrency series from Kevin goes through a lot of these concepts in a lot more detail - I highly recommend reading the series. I hope that this post adds some value to that series by presenting some additional examples, especially viewed from the perspective of someone trying to use shared Kotlin multiplatform code in an iOS app.
The Rules and Concepts
As a review, the rules are that an object is either:
immutable (and therefore can be shared across multiple threads), or
mutable (and is confined to a single thread)
One important concept that applies heavily here:
freeze - freezes an object to be shared between threads - a frozen object is immutable (for the most part) - more on this later.
So let’s take some examples and see how things play out.
The Immutable Case
Singleton Objects
Let’s say that we have written the following multiplatform code:
// note - MathUtil() doesn't make a new instance, it's just// Swift syntax for getting the instance of the object.print(NSString(format: "number^2 is: %d", MathUtil().square(4)))
We can also do:
DispatchQueue.global(qos: .background).async {
print(NSString(format: "number^2 from bg is: %d", MathUtil().square(4)))
}
This second example, while obvious in retrospect, is one I never realized before - you can actually call Kotlin multiplatform code from multiple threads on iOS if the code is immutable. This is a super useful building block that can be used for more complicated examples.
Note that this also works if MathUtil has some immutable val properties in there, because Kotlin Native will freeze those properties by default. This is an important point that will come into play later.
Class Instances
Let’s change things around a bit and consider a normal class (not an object/singleton).
In iOS, we can use a fresh instance from this class in any thread:
let ops = NumericOperations(amount: 42)
print(NSString(format: "42^2 from main: %d", ops.square()))
DispatchQueue.global(qos: .background).async {
let bgOps = NumericOperations(amount: 10)
print(NSString(format: "10^2 from background: %d", bgOps.square()))
}
Now what if we replaced bgOps.square() in the body of the async lambda in the snippet above with ops.square()? We’d get a crash:
Uncaught Kotlin exception: kotlin.native.IncorrectDereferenceException: illegal attempt to access non-shared net.cafesalam.test.common.NumericOperations@337f9a8 from other thread
at 0 SharedCode 0x0000000105ca0777 kfun:kotlin.Throwable.<init>(kotlin.String?)kotlin.Throwable +87 (/Users/teamcity1/teamcity_work/4d622a065c544371/runtime/src/main/kotlin/kotlin/Throwable.kt:22:37)
at 1 SharedCode 0x0000000105c93545
This is saying that we cannot use the instance of NumericOperations that we made on one thread on another, because it’s a non-shared instance.
But why? Doesn’t the first rule say that immutable objects (which our class instance clearly is), can be shared across multiple threads? To quote the Stranger Threads post:
As far as the KN runtime is concerned, all non-frozen state is possibly mutable, and restricted to one thread.
So let’s suppose (for whatever reason) that we actually wanted to share the same instance across multiple threads - we can do this by freezing the instance. To do this, we can do something like this:
So far, all the examples have only dealt with immutable data and data that could easily be frozen - let’s try adding some mutable state into the mix.
Singleton Objects
Let’s consider this example -
objectCounter {
var count: Int = 0}
If we try to use this from Swift from the main thread:
let counter = Counter()
counter.count = 42print(counter.count)
When we try to modify count, we get a crash -
Uncaught Kotlin exception: kotlin.native.concurrent.InvalidMutabilityException: mutation attempt of frozen net.cafesalam.test.common.SecondTestClass@a6abe8
at 0 SharedCode 0x000000010f42a777 kfun:kotlin.Throwable.<init>(kotlin.String?)kotlin.Throwable +87 (/Users/teamcity1/teamcity_work/4d622a065c544371/runtime/src/main/kotlin/kotlin/Throwable.kt:22:37)
This is because the properties of an object are all frozen by default. We can override this behavior by using the @ThreadLocal annotation on the object. @ThreadLocal says that each thread gets its own copy of this object.
Changing the code to instead look like this fixes the issue:
@ThreadLocalobjectCounter {
var count: Int = 0}
Now on iOS, we can do something like:
let counter = Counter()
counter.count = 42DispatchQueue.global(qos: .background).async {
let secondCounter = Counter()
secondCounter.count = 43// original counter is still at 42 because of ThreadLocal}
Note that if we were to try to share an instance between the ui thread and a background thread, we’d get an exception (due to the rules - if it’s not immutable, it must be confined to a single thread).
Class Instances
For normal classes with mutable variables, things are pretty straightforward - these instances are confined to the thread they were made on per the rules.
What if we want to have something be mutable but also be usable from multiple threads? How can we make that work?
Remember back to the point about freeze, when I mentioned that “a frozen object is immutable (for the most part).” The for the most part piece is because Kotlin Native has several interesting types - the atomic types. To quote the documentation:
Atomic values and freezing: atomics AtomicInt, AtomicLong, AtomicNativePtr and AtomicReference are unique types with regard to freezing. Namely, they provide mutating operations, while can participate in frozen subgraphs. So shared frozen objects can have fields of atomic types.
These types can exist within a frozen type (therefore being frozen) and can still be modified. So for example, we can have some shared code that looks like this:
// common/src/iosMain/kotlin/net/cafesalam/test/AtomicCounter.kt
objectAtomicCounter {
privateval count = AtomicInt(0)
funget(): Int = count.valuefunincrease(): Int = count.addAndGet(1)
}
We can then use this on iOS from multiple threads:
// main threadlet atomic = AtomicCounter()
// first incrementatomic.increase()
DispatchQueue.global(qos: .background).async {
// object is singleton, so this is the same instance as if we were// to just use atomic here directly.let counter = AtomicCounter()
print(counter.get()) // prints 1 counter.increase()
print(counter.get()) // prints 2}
Using these, especially AtomicReference, makes life a lot easier. Note that, like freeze, these types are only available in native code (i.e. not on jvm).
Global State
Top level values are (by default) declared on the main thread, preventing them from being used on other threads. Consider:
We can make an instance of TestClass on the main thread and use it, but we cannot make a fresh instance on a background thread. If we try to, we’d get an exception:
Uncaught Kotlin exception: kotlin.native.IncorrectDereferenceException: Trying to access top level value not marked as @ThreadLocal or @SharedImmutable from non-main thread
at 0 SharedCode 0x00000001045f15d7 kfun:kotlin.Throwable.<init>(kotlin.String?)kotlin.Throwable +87 (/Users/teamcity1/teamcity_work/4d622a065c544371/runtime/src/main/kotlin/kotlin/Throwable.kt:22:37)
We can use the @ThreadLocal or @SharedImmutable annotations on the top level variable to fix this issue (depending on the behavior we want). @SharedImmutable says that this variable is immutable and therefore can be shared across threads (essentially making it frozen).
Other Points
Note that, up until now, there has been no mention of the Worker class, nor of coroutines - this is pretty neat because, even without these, the existing building blocks allow us to run code in multiple threads on the target system itself.
It would be great to write shared code that does multithreading for us as well directly in commonMain. Both the Worker class and coroutines can be used to do this.
Here’s a small example using coroutines:
objectBackgroundCalculator {
fundoSomeWork(param: Param, callback: ((Result) -> Unit)) {
GlobalScope.launch {
val result = withContext(Dispatchers.Default) {
// heavy operation here that returns a Result
}
withContext(Dispatchers.Main) {
lambda(result)
}
}
}
}
Using the org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.5-native-mt artifact (or something newer), Dispatchers.Default is now backed by a single background thread, whereas Dispatchers.Main will point to the correct main thread on iOS / Android.
In this example, calling BackgroundCalculator.doSomeWork will return right away. Some time later, once the heavy calculation is done, it will call the callback that is passed in from the main thread. A sample usage could look like this:
BackgroundCalculator().doSomeWork(param: parameter) { (result: [Result]) in// this will be called on the main thread print(result)
}
Note that objects will be frozen when they are transferred between threads using withContext. This also shows how the basic rules that we mentioned at the very start of the article apply, even with context of coroutines and workers.
Tips
Q: Why couldn’t the cows moo at the same time?
A: Because they had a mootex.
isFrozen / ensureNeverFrozen
isFrozen and ensureNeverFrozen are your friends for debugging things. These are available in native code only, but using Stately’s common artifact, you can use them in common code only. (Note - Stately exposes these in common by implementing them as expect, where the actual implementation on native calls to the actual isFrozen / ensureNeverFrozen, and otherwise just returns false or does nothing).
i expect-ed this to be fun, but I didn’t realize that it would actually be fun.
Unit tests
Unit tests can help find many issues - for example:
This innocent looking test will fail with InvalidMutabilityException if you run it on iOS (i.e. ./gradlew :common:iosTest). We fix this by adding @ThreadLocal above the object declaration as mentioned above.
I learned this (and some other tricks - like how to test and catch the issue of global state without annotations not being accessible off the ui thread) - from this post from Jake Wharton. I really recommend reading it.
Special thanks to Kevin Galligan for reviewing this post.
TL;DR - adding default interface functions in Kotlin constitutes a source-compatible (but not necessarily a binary-compatible) change.
Whenever I used to run across issues mentioning binary compatibility, I used to always think, “this is super interesting, but that’s for library developers, I don’t have to worry about this while working on apps.” Little did I know how wrong I was.
This is a short story of my first encounter with an AbstractMethodError. But first, allow me to give a brief explanation of the landscape that made this possible.
Libraries and Modularization
As companies have to build and support multiple apps, it starts to make sense to share code or features across the apps. Larger companies sometimes achieve this sharing by referencing code via monorepos.
For smaller companies, pushing internal libraries and features to a private maven repository is a good way to achieve this. This is where my story begins.
Setting the Stage
Let’s say I have a common styles library that I share between my apps. Imagine that version 1.0 of this library, net.helw.common.ui:style:1.0, has one interface that looks like this:
interfaceColorProvider {
funcolorPrimary(): Int
funcolorSecondary(): Int
}
Let’s also say that I share a common login feature between my apps - and suppose that version 1.0 of my login feature, net.helw.app.feature:login:1.0, has this implementation:
My Android app (which depends on net.helw.app.feature:login:1.0 and on net.helw.common.ui:style:1.0) has a class that looks like this:
objectColorUtil {
fungetContrastingColor(colorProvider: ColorProvider): Int {
// logic here
}
}
So far everything is great!
A Well-Intentioned Change
The designer asks me to add a new colorPrimaryVariant, and says that “in some cases it can be the same as colorPrimary, but generally it will be different.”
Ok, so I’ll just update ColorProvider to support colorPrimaryVariant and publish a new version. I don’t want to break compatibility, so I will use a default method, thinking I’ll be safe:
interfaceColorProvider {
funcolorPrimary(): Int
funcolorSecondary(): Int
funcolorPrimaryVariant(): Int = colorPrimary()
}
I update the net.helw.common:ui:style library to 1.1 and push it to maven.
I also update my app to use this 1.1 version of the library and update getContrastingColor call to use this colorPrimaryVariant method and use it in my computation somehow. My app still depends on versions 1.0 of login.
I compile my app and run it. I expected everything to work, but instead, I was greeted with an error that looked like this:
Exception in thread "main" java.lang.AbstractMethodError: ColorUtil.colorPrimaryVariant()
Digging Deeper
If we look at the decompiled Java bytecode for ColorProvider after adding the method, it looks something like this:
So we can see that at compile-time, Kotlin added an implementation of colorPrimaryVariant to my interface, asking it to call the static method it added.
But if this is the case, why did it break?
Classes in the AAR
An aar typically contains (among resources and other things) a file named classes.zip. This file contains .class files of all the code in the library (not source files). This means that my login aar file contains class files as they were compiled against the 1.0 version of my styles library.
In other words, the decompiled Java code of my LoginColorProvider really looks like this:
If I were to recompile the login module against the 1.1 version of styles, the new method implementation would be there (calling the static method), but since I didn’t, it is nowhere to be seen.
Putting it all Together
My login feature was compiled against styles 1.0 (without the new method). But because my actual app used styles 1.1 to call the new method, it requested Gradle to use styles 1.1. Gradle saw the conflict and upgraded everything to use 1.1. Everything compiles fine - but at runtime, we get an AbstractMethodError because the new method can’t be found.
This is an example of binary compatibility breaking.
Solution
The easiest solution here is to publish an updated versions of login that depends on the 1.1 version of the style library.
Alternatively, instead of adding a default implementation, we can have sub-interfaces and the code can check against them as is appropriate. For example:
interfaceVariantColorProvider : ColorProvider {
funcolorPrimaryVariant(): Int = colorPrimary()
}
This way, getContrastingColor could look like this:
objectColorUtil {
fungetContrastingColor(colorProvider: ColorProvider): Int {
returnwhen (colorProvider) {
is VariantColorProvider -> colorProvider.colorPrimaryVariant()
else-> colorProvider.colorPrimary()
}
}
}
There doesn’t seem to be any static analysis solutions for catching this type of issue, but the japicmp tool can be used to detect when a binary incompatible change is made between two library versions.
There is, however, one more solution.
What about Java 8 / @JvmDefault?
I was curious, “are the default interface methods in Java 8 binary compatible?” - turns out that the answer to this is “mostly.” The JLS says:
Adding a default method, or changing a method from abstract to default, does not break compatibility with pre-existing binaries, but may cause an IncompatibleClassChangeError if a pre-existing binary attempts to invoke the method.
(Note that the JLS link also shows an interesting case where you’d get an IncompatibleClassChangeError that’s worth looking at. Because of this, japicmp, the open source API comparison tool, intentionally marks new methods in interfaces as errors).
In our case, if we were asking Kotlin to output Java 8 code with the -Xjvm-default=enable flag (and with the @JvmDefault annotation), everything would actually work. (Note: Zac Sweers has an in-depth article about @JvmDefault that’s worth reading).
On Android, if we annotate the default method as @JvmDefault and set the -Xjvm-default=enable flag, D8 will actually add a synthetic method to call through to the static method, even if the class was compiled against the old version of the library!