Modification d’une dépendance à la compilation d’un projet Android

Vincent Bernat

Dans cet ar­ticle, je pré­sente une mé­thode pour mo­di­fier une dé­pen­dance ex­terne dans un pro­jet An­droid lors de la com­pi­la­tion avec Gradle. Cette mé­thode met en œuvre l’API « Trans­form » et Ja­vas­sist, un uti­li­taire de ma­ni­pu­la­tion du by­te­code Java.

buildscript {
    dependencies {
        classpath 'com.android.tools.build:gradle:2.2.+'
        classpath 'com.android.tools.build:transform-api:1.5.+'
        classpath 'org.javassist:javassist:3.21.+'
        classpath 'commons-io:commons-io:2.4'
    }
}

À no­ter que je ne suis pas un pro­gram­meur ex­pé­ri­menté avec An­droid. Il est donc pos­sible que cet ar­ticle contienne des er­reurs plus ou moins grossières.

Contexte#

Cette sec­tion contex­tua­lise l’exemple qui va ser­vir de sup­port. Sa lec­ture n’est pas es­sentielle.

Da­sh­kiosk est une ap­pli­ca­tion qui per­met de gé­rer des ta­bleaux de bord sur di­vers écrans. Elle dis­pose d’une ap­pli­ca­tion An­droid com­pa­gnon que l’on peut ins­tal­ler sur les clefs An­droid bon mar­ché. En cou­lisses, l’ap­pli­ca­tion em­barque une web­view gé­rée par le pro­jet Cross­walk. Cela per­met d’avoir un mo­teur de rendu web à jour, quelle que soit la ver­sion d’Android1.

Ré­cem­ment, une vul­né­ra­bi­lité a été dé­cou­verte dans la mé­mo­ri­sa­tion des cer­ti­fi­cats in­va­lides. Quand un cer­ti­fi­cat ne peut être vé­ri­fié, la web­view dé­lègue la dé­ci­sion à l’ap­pli­ca­tion en in­vo­quant la mé­thode onReceivedSslError() :

In­dique à l’ap­pli­ca­tion qu’une er­reur SSL a eu lieu en char­geant une res­source. L’ap­pli­ca­tion doit ap­pe­ler callback.​onReceiveValue(true) ou callback.​onReceiveValue(false). La dé­ci­sion peut être réuti­li­sée pour d’autres er­reurs SSL. Le com­por­te­ment par dé­faut est d’af­fi­cher une boîte de dialogue.

Le com­por­te­ment par dé­faut est spé­ci­fique à Cross­walk : la web­view d’An­droid se contente d’an­nu­ler le char­ge­ment de la res­source. Mal­heu­reu­se­ment, le cor­rec­tif ap­pli­qué dans Cross­walk est dif­fé­rent et a pour ef­fet de bord de ne plus te­nir compte de la mé­thode onReceivedSslError().

Da­sh­kiosk pro­pose une op­tion pour igno­rer les er­reurs TLS2. Le cor­rec­tif casse cette fonc­tion­na­lité. L’exemple qui suit montre com­ment mo­di­fier Cross­walk pour res­tau­rer l’an­cien com­por­tement3.

Remplacement d’une méthode#

Nous al­lons rem­pla­cer la mé­thode shouldDenyRequest() de la classe org.​xwalk.​core.​internal.​SslUtil avec cette version :

// Dans la classe SslUtil
public static boolean shouldDenyRequest(int error) {
    return false;
}

Squelette de la transformation#

L’API de trans­for­ma­tion de Gradle per­met de ma­ni­pu­ler les fi­chiers de classes avant que ceux-ci ne soient conver­tis au for­mat DEX. Pour dé­cla­rer une trans­for­ma­tion, nous ajou­tons le code sui­vant au fi­chier build.gradle:

import com.android.build.api.transform.Context
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInput
import com.android.build.api.transform.TransformOutputProvider
import org.gradle.api.logging.Logger

class PatchXWalkTransform extends Transform {
    Logger logger = null;

    public PatchXWalkTransform(Logger logger) {
        this.logger = logger
    }

    @Override
    String getName() {
        return "PatchXWalk"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return Collections.singleton(QualifiedContent.DefaultContentType.CLASSES)
    }

    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return Collections.singleton(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
    }

    @Override
    boolean isIncremental() {
        return true
    }

    @Override
    void transform(Context context,
                   Collection<TransformInput> inputs,
                   Collection<TransformInput> referencedInputs,
                   TransformOutputProvider outputProvider,
                   boolean isIncremental) throws IOException, TransformException, InterruptedException {
        // Il faudrait faire quelque chose ici...
    }
}

// Enregistrement de la transformation
android.registerTransform(new PatchXWalkTransform(logger))

La mé­thode getInputTypes() re­tourne un en­semble de types de don­nées gé­rés par la trans­for­ma­tion. Dans notre cas, nous al­lons trans­for­mer des classes. Une autre pos­si­bi­lité est de trans­for­mer des res­sources.

La mé­thode getScopes() in­dique la por­tée de la trans­for­ma­tion. Dans notre cas, nous ne vou­lons trans­for­mer que les dé­pen­dances ex­ternes. Il est aussi pos­sible de trans­for­mer ses propres classes.

Telle qu’im­plé­men­tée, la mé­thode isIncremental() in­dique que l’on sait gé­rer la construc­tion in­cré­men­tale. Ce n’est pas très com­pli­qué à faire.

La mé­thode transform() prend un en­semble de fi­chiers en en­trée et doit les co­pier (avec ou sans mo­di­fi­ca­tions) à l’en­droit que lui in­dique son qua­trième ar­gu­ment. Nous n’avons pas en­core im­plé­menté cette fonc­tion. Du coup, ac­tuel­le­ment, la trans­for­ma­tion re­tire juste toutes les dé­pen­dances ex­ternes de l’ap­plication.

Transformation à vide#

Pour gar­der toutes les dé­pen­dances ex­ternes sans ap­por­ter de mo­di­fi­ca­tions, nous de­vons co­pier les fi­chiers fournis :

@Override
void transform(Context context,
               Collection<TransformInput> inputs,
               Collection<TransformInput> referencedInputs,
               TransformOutputProvider outputProvider,
               boolean isIncremental) throws IOException, TransformException, InterruptedException {
    inputs.each {
        it.jarInputs.each {
            def jarName = it.name
            def src = it.getFile()
            def dest = outputProvider.getContentLocation(jarName,
                                                         it.contentTypes, it.scopes,
                                                         Format.JAR);
            def status = it.getStatus()
            if (status == Status.REMOVED) { // ❶
                logger.info("Remove ${src}")
                FileUtils.delete(dest)
            } else if (!isIncremental || status != Status.NOTCHANGED) { // ❷
                logger.info("Copy ${src}")
                FileUtils.copyFile(src, dest)
            }
        }
    }
}

Le code ci-des­sus né­ces­site deux im­ports ad­di­tionnels :

import com.android.build.api.transform.Status
import org.apache.commons.io.FileUtils

Comme nous ne gé­rons que des dé­pen­dances ex­ternes, nous n’avons qu’à nous oc­cu­per de fi­chiers JAR. Ainsi, l’ité­ra­tion a lieu uni­que­ment sur jarInputs et non sur directoryInputs. Lors d’une construc­tion in­cré­men­tale, il y a deux cas à gé­rer : soit le fi­chier a été re­tiré (❶), soit il a été mo­di­fié (❷). Dans les autres cas, nous consi­dé­rons que le fi­chier est déjà à sa place.

Modification d’un JAR#

Lors du trai­te­ment du fi­chier JAR de Cross­walk, nous de­vons aussi le mo­di­fier. Voici la pre­mière par­tie du code (en rem­pla­ce­ment du code en ❷) :

if ("${src}" ==~ ".*xwalk_core_library.*/classes.jar") {
    def pool = new ClassPool()
    pool.insertClassPath("${src}")
    def ctc = pool.get('org.xwalk.core.internal.SslUtil') // ❸

    def ctm = ctc.getDeclaredMethod('shouldDenyRequest')
    ctc.removeMethod(ctm) // ❹

    ctc.addMethod(CtNewMethod.make("""
public static boolean shouldDenyRequest(int error) {
    return false;
}
""", ctc)) // ❺

    def sslUtilBytecode = ctc.toBytecode() // ❻

    // Écriture du nouveau fichier JAR
    // …
} else {
    logger.info("Copy ${src}")
    FileUtils.copyFile(src, dest)
}

Il est né­ces­saire d’ajou­ter les im­ports sui­vants pour uti­li­ser Ja­vassist :

import javassist.ClassPath
import javassist.ClassPool
import javassist.CtNewMethod

Lorsque le fi­chier en cours est ce­lui que nous vou­lons mo­di­fier, nous l’ajou­tons à notre class­path et ré­cu­pé­rons la classe qui nous in­té­resse (❸). Cela nous per­met d’ef­fa­cer la mé­thode shouldDenyRequest() (❹). En­suite, nous pou­vons ajou­ter notre propre ver­sion (❺). La der­nière étape consiste à ré­cu­pé­rer le by­te­code cor­res­pon­dant à la classe mo­di­fiée (❻).

Il reste en­fin à re­cons­truire le fi­chier JAR :

def input = new JarFile(src)
def output = new JarOutputStream(new FileOutputStream(dest))

// ❼
input.entries().each {
    if (!it.getName().equals("org/xwalk/core/internal/SslUtil.class")) {
        def s = input.getInputStream(it)
        output.putNextEntry(new JarEntry(it.getName()))
        IOUtils.copy(s, output)
        s.close()
    }
}

// ❽
output.putNextEntry(new JarEntry("org/xwalk/core/internal/SslUtil.class"))
output.write(sslUtilBytecode)

output.close()

Il convient d’im­por­ter les classes suivantes :

import java.util.jar.JarEntry
import java.util.jar.JarFile
import java.util.jar.JarOutputStream
import org.apache.commons.io.IOUtils

Il y a deux étapes. En ❼, toutes les classes, à l’ex­cep­tion de SslUtil, sont co­piées dans le nou­veau fi­chier JAR. En ❽, le by­te­code pré­cé­dem­ment mo­di­fié pour SslUtil est ajouté.

C’est tout ! L’exemple com­plet est dis­po­nible sur GitHub.

Fonctions avec dépendances#

Dans l’exemple pré­cé­dent, la nou­velle mé­thode n’uti­li­sait au­cune dé­pen­dance ex­terne. Sup­po­sons main­te­nant que nous vou­lons rem­pla­cer la mé­thode sslErrorFromNetErrorCode() de la même classe avec celle-ci :

import org.chromium.net.NetError;
import android.net.http.SslCertificate;
import android.net.http.SslError;

// Dans la classe SslUtil
public static SslError sslErrorFromNetErrorCode(int error,
                                                SslCertificate cert,
                                                String url) {
    switch(error) {
        case NetError.ERR_CERT_COMMON_NAME_INVALID:
            return new SslError(SslError.SSL_IDMISMATCH, cert, url);
        case NetError.ERR_CERT_DATE_INVALID:
            return new SslError(SslError.SSL_DATE_INVALID, cert, url);
        case NetError.ERR_CERT_AUTHORITY_INVALID:
            return new SslError(SslError.SSL_UNTRUSTED, cert, url);
        default:
            break;
    }
    return new SslError(SslError.SSL_INVALID, cert, url);
}

La prin­ci­pale dif­fé­rence est l’im­por­ta­tion de classes sup­plé­mentaires.

Import du SDK Android#

Le SDK An­droid ne fait pas par­tie des dé­pen­dances ex­ternes. Il convient de l’im­por­ter ma­nuel­le­ment. Le che­min com­plet vers le fi­chier JAR est :

androidJar = "${android.getSdkDirectory().getAbsolutePath()}/platforms/" +
             "${android.getCompileSdkVersion()}/android.jar"

Il faut l’ajou­ter au class­path avant d’ajou­ter la nou­velle mé­thode dans la classe SslUtil :

def pool = new ClassPool()
pool.insertClassPath(androidJar)
pool.insertClassPath("${src}")
def ctc = pool.get('org.xwalk.core.internal.SslUtil')
def ctm = ctc.getDeclaredMethod('sslErrorFromNetErrorCode')
ctc.removeMethod(ctm)
pool.importPackage('android.net.http.SslCertificate');
pool.importPackage('android.net.http.SslError');
// …

Import d’une autre dépendance externe#

Nous de­vons aussi im­por­ter org.​chromium.​net.​NetError et donc mettre le fi­chier JAR ap­pro­prié dans le class­path. La fa­çon la plus simple de faire ceci est d’ité­rer de nou­veau sur toutes les dé­pen­dances ex­ternes et de les ajou­ter au classpath :

def pool = new ClassPool()
pool.insertClassPath(androidJar)
inputs.each {
    it.jarInputs.each {
        def jarName = it.name
        def src = it.getFile()
        def status = it.getStatus()
        if (status != Status.REMOVED) {
            pool.insertClassPath("${src}")
        }
    }
}
def ctc = pool.get('org.xwalk.core.internal.SslUtil')
def ctm = ctc.getDeclaredMethod('sslErrorFromNetErrorCode')
ctc.removeMethod(ctm)
pool.importPackage('android.net.http.SslCertificate');
pool.importPackage('android.net.http.SslError');
pool.importPackage('org.chromium.net.NetError');
ctc.addMethod(CtNewMethod.make("…"))
// Puis reconstruction du fichier JAR...

Happy hacking !