Après l'activation de ProGuard sur Android

le 12/02/2018 par Louis Davin
Tags: Software Engineering

Cet article est la seconde partie d’un sujet sur ProGuard. Retrouvez la première partie revenant sur les bases de l’outil dans l’écosystème Android.

On peut s'armer de beaucoup de bonne volonté, bien se documenter sur le fonctionnement de ProGuard, ses concepts et ses phases d'exécution, le « passage à l'acte » et son activation sur un projet restent un moment délicat.

Avant de paniquer et de nous ruer sur la première réponse stackoverflow promettant de « corriger » le build (souvent en désactivant la moitié des fonctionnalités de ProGuard…), revenons tranquillement sur les impacts de l'outil sur notre base de code. Nous allons parcourir les 3 obstacles ou sources d'erreurs principales dans nos applications Android, apprendre à décompiler notre code pour comprendre comment certains problèmes subsistent, et ainsi trouver le réglage fin permettant de s'en débarrasser.

1. Les warnings

La première étape, après chaque activation de ProGuard sur un nouveau projet, est de se débarrasser des warnings qui sont levés par l'outil.

La plupart des warnings obtenus sont généralement de ces deux types : Warning: can't find superclass or interface Warning: can't find referenced class

Ici, ProGuard essaie de nous dire que certaines classes de l'application (ou des dépendances que vous récupérez), font référence à d'autres classes que ProGuard n'est pas capable de trouver dans le classpath.

Par exemple, si vous référencez (directement ou transitivement) la librairie Okio de Square comme dépendance sur un vieux projet Android (ciblant Java 6), vous obtiendrez ces warnings à l'exécution :

Warning: okio.Okio: can't find referenced class java.nio.file.Files Warning: okio.Okio: can't find referenced class java.nio.file.Path Warning: okio.Okio: can't find referenced class java.nio.file.OpenOption

ProGuard s'affole de voir du code faisant référence à ces classes, alors qu'elles n'existent pas en Java 6. Dans le cas d'un projet Android, il est fréquent de retrouver ces warnings lorsqu'on importe une librairie Java utilisant des classes absente du SDK de Google (comme par exemple des composants Swing ou AWT).

La solution à ce type de warning est assez simple : il suffit de tester son app à fond et vérifier que tout fonctionne normalement. Si c'est le cas, alors on peut désactiver les warnings sans danger. Pourquoi ? À l'exécution du code, avec ou sans ProGuard, l'absence de ces classes provoquerait un crash dans l'application (ClassNotFoundException). Si les librairies utilisées sont open-source, il est recommandé de vous rendre sur leur page GitHub. Les fichiers sources ou les développeurs pourront vous indiquer s’il y a, ou non, un risque à l’exécution.

Dans l’exemple d'Okio, on désactiverait les warnings avec -dontwarn okio.Okio.

D'autres warnings peuvent apparaître de temps en temps au cours du build. Ils sont listés dans la documentation, certains ont même un focus Android dans leur explication.

2. La réflexion

Le projet compile avec ProGuard activé ? Bien, mais cela ne signifie pas que tout fonctionne correctement dans l'application. En particulier si du code fait appel aux API de réflexion Java.

Imaginons que du code correspondant à l'exemple suivant soit présent dans votre application :

class ProblematicClass { fun painfulMethod() { // Does something } } … val c = Class.forName("ProblematicClass") val subject = c.getConstructor().newInstance() val method = c.getDeclaredMethod("painfulMethod") method.invoke(subject)

Ce code, classique bien qu’un peu étrange, deviendra problématique une fois ProGuard activé :

  • Si, dans la base de code, il n'y a aucune référence « classique » (avec un import) qui soit faite à la classe « ProblematicClass », ProGuard aura l'impression qu'elle n'est jamais utilisée, et donc la supprimera lors de la phase de shrink. Il y aura un crash à l’exécution.
  • Si elle est référencée quelque part, mais qu'elle n'est jamais instanciée, ProGuard aura l'impression que le constructeur de la classe n'est jamais utilisé. Tous les constructeurs non utilisés seront supprimés du code lors du shrink, et le constructeur par défaut verra sa visibilité passer en private lors de la phase d'optimize. Autre crash à l’exécution.
  • Si la classe est référencée quelque part, instanciée, mais que la méthode « painfulMethod » n'est jamais appelée explicitement, ProGuard aura l'impression qu'il s'agit de code mort, et supprimera donc la méthode lors de la phase de shrink… Encore un crash.

Vous visualisez maintenant d'où peuvent venir les problèmes liés à la réflexion. En réalité sur cet exemple, tout continuera à fonctionner, car les liens vers la classe ProblematicClass et la méthode painfulMethod sont passés sous forme de strings « en dur » dans notre code, et ProGuard est assez puissant pour le détecter. Quelques détails sont donnés dans la documentation (points ClassNotFoundException, NoSuchFieldException et NoSuchMethodException) à ce sujet.

En revanche, le problème existera bel et bien si la réflexion se fait de manière dynamique (par exemple : en parcourant dynamiquement la liste des méthodes d'une classe).

Il y a peu de chance pour que du code de ce style soit présent dans vos sources, certes. Par contre, il y a de grandes chances que le parseur JSON ou XML que vous utilisez fonctionne sur ce principe !

Pour les librairies de parsing et celles qui utilisent de la réflexion en interne, il y a deux étapes :

  1. S'assurer qu'elles fonctionnent toujours (pas de crash ou bug interne à la librairie)
  2. S'assurer que le code avec lequel elles interagissent dans nos sources (les POJOs dans lesquels dé-sérialiser le JSON par exemple) ne soit pas modifié par ProGuard au point de provoquer un crash ou un bug

Dans le cas spécifique du parsing, fréquemment rencontré sur mobile, la meilleure solution est de ne pas s'appuyer sur la réflexion mais d'écrire à la main, ou mieux, générer le code réalisant le binding entre l'objet et le JSON. Par exemple, avec moshi on écrira un Adapter pour chaque type (en évitant donc le JsonAdapter qui fonctionne par réflexion). Pour gson, on écrira un TypeAdapter associé à chaque type à sérialiser/désérialiser. Ce TypeAdapter pourra être généré automatiquement notamment par auto-value (grâce à une extension) ou Immutables s'ils sont utilisés pour spécifier les POJOs. Avec cette approche, il n’y aura normalement aucun effet de bord causé par ProGuard.

Si l’application se base sur un parsing avec réflexion (ce qui est généralement le cas), on ira progressivement écrire des règles pour limiter les problèmes. Prenons l’exemple d’un POJO « à l’ancienne » servant à sérialiser/désérialiser un objet User, écrit de deux manières différentes :

public class User {<br><br> private long id;<br> private String name;<br><br> public long getId() { return id; }<br> public void setId(long id) { this.id = id; }<br> public String getName() { return name; }<br> public void setName(String name) { this.name = name; }<br>}public class User {<br><br> @SerializedName("id") private long id;<br> @SerializedName("name") private String name;<br><br> public long getId() { return id; }<br> public void setId(long id) { this.id = id; }<br> public String getName() { return name; }<br> public void setName(String name) { this.name = name; }<br>}

Afin d’éviter les mauvaises surprises au runtime, on écrira les règles ProGuard suivantes :

  • Premièrement, une règle pour ne pas supprimer les membres des POJOs concernés. C’est à dire, dans notre exemple, les attributs et getter/setters associés. Exemple de règle : -keepclassmembers,allowobfuscation class com.sample.User { *; }
  • Si les éléments ne sont pas annotés (avec @Json pour moshi, @SerializedName pour gson ou encore @JsonProperty pour Jackson), comme dans la version de gauche de notre POJO, il faudra s’assurer que les noms de variables et les getter/setters ne sont pas renommés à l’obfuscation. On pourra par exemple enlever l’option allowobfuscation de la règle précédente pour obtenir : -keepclassmembers class com.sample.User { *; }
  • Si les éléments sont annotés, comme dans la version de droite de notre POJO, on peut laisser l’obfuscation se faire, mais il faut vérifier que la règle indiquant à ProGuard de ne pas supprimer les annotations du bytecode est bien présente. -keepattributes *Annotation*

Dans la partie 4 nous verrons une méthode pour itérer efficacement sur ces règles, en décompilant l'application, afin de comprendre rapidement ce qui manque dans le code ou ce qui change trop dans nos POJOs, pour y remédier.

3. Injections JavaScript dans les WebViews

Si vous avez développé des interfaces JavaScript pour certaines de vos webviews, vous allez devoir « protéger » les méthodes Java concernées. Pour rappel, créer une interface JavaScript consiste à exposer une méthode Java au code JavaScript tournant dans la webview. La plupart du temps, cela permet de faire remonter de l'information depuis la webview (information utilisateur, succès ou erreur d'une action, callback de complétion…).

Depuis JellyBean, il faut obligatoirement annoter les méthodes que l'on veut exposer au JavaScript avec @JavascriptInterface en plus de les rendre publiques.

On doit donc ajouter une règle à notre fichier de configuration, pour que ProGuard ne supprime pas ces méthodes qui peuvent sembler non-utilisées, ne les renomme pas (le JavaScript ne pourrait pas connaitre leur nom obfusqué), ni ne baisse leur visibilité. Comme vu dans la partie 2, il ne faudra pas non plus oublier d'indiquer à ProGuard de ne pas supprimer les annotations du bytecode :

-keepattributes *Annotation* -keepclassmembers class * { @android.webkit.JavascriptInterface <methods>; }

4. Compréhension et résolution des problèmes

Ce n'est pas parce que l'application compile à nouveau avec ProGuard qu'elle fonctionne encore. C'est pour cela qu'il est recommandé d'activer ProGuard sur les builds de release dès la création du projet. Cela permet aux PO et aux recetteurs de détecter au fur et à mesure du développement les potentiels problèmes causés par ProGuard.

Mais lorsqu'un problème est là, causé par ProGuard, comment le résoudre si on ne le comprend pas ?

Lorsque le seul élément permettant d'avancer est une stack-trace obscure ou bien un log incompréhensible de la librairie de parsing, on reste souvent bloqué dans un cercle vicieux : on modifie la configuration ProGuard, on compile, on relance l'application, et on reproduit le problème, le tout en restant toujours dans le flou. En procédant de la sorte, on a vite fait de perdre patience.

En réalité, lorsqu'on rencontre un problème étrange avec ProGuard, la solution la plus simple est de décompiler l'application pour comprendre ce qu'il s'y passe.

Une fois le code décompilé, il n'y a plus qu'à se rendre dans les sources concernées, et être observateur : des annotations, attributs, méthodes ou classes ont-elles disparu ? Ont-elles été renommées ? Cela peut-il être la cause du problème ?

On peut alors essayer de modifier la configuration ProGuard, recompiler l'application, la décompiler pour voir si le résultat est bien celui attendu, puis vérifier sur l'appareil si tout fonctionne à nouveau.

Pour décompiler l'application et obtenir des sources lisibles sans perdre de temps, on peut par exemple utiliser jadx et sa GUI. En deux lignes pour les utilisateurs de Mac : brew install jadx puis jadx-gui.

Avec une bonne compréhension du fonctionnement de ProGuard, de la manière de le configurer et des impacts qu'il va avoir sur une application, plus besoin de tâtonner : on peut appliquer autant de rigueur et de patience à sa configuration que celle que l'on aime dépenser pour dépasser nos problèmes techniques quotidiens.

Alors que nous concentrons une grande partie de nos journées de développeurs mobiles à produire un code clair, lisible et maintenable, n'oublions pas qu'il est destiné à être exécuté sur des appareils aux capacités limitées. Ce n'est pas parce que la puissance de calcul, la quantité de mémoire disponible, et la capacité des batteries des téléphones continuent sans cesse d'augmenter que nous devons nous priver d'un outil permettant, sans presque aucun compromis sur notre code, de le rendre un peu moins lourd, un peu moins vorace et un peu plus efficace pour nos téléphones.

De plus, si vous avez déjà essayé de décompiler un apk, vous avez pu vous rendre compte de la facilité à « rétro-ingénieurer » son code. Dans de nombreux contextes, cette facilité à comprendre le code de l’application et notamment le back-end sur lequel elle repose est problématique. Les phases obfuscate et optimize de ProGuard permettront de rendre le code assez incompréhensible pour décourager la plupart des attaquants.

Les plus au courant d’entre-vous peuvent se dire qu’il ne sert plus à rien, aujourd’hui, de se pencher sur ProGuard, étant donné que Google a annoncé son futur remplacement dans la toolchain par R8. Nous avons très peu d’informations sur R8 pour le moment. La différence notable vis à vis de proguard est sa capacité à transformer directement des .class en .dex (quand proguard optimise les .class pour produire de nouveaux .class). Mais dans tous les cas, il y a pour l’instant très peu de changements à prévoir, le format de configuration de R8 sera le même que celui de ProGuard : même système de points d’entrée, et même façon d’écrire les règles.