Visualiser un fichier Office (doc, xls, ppt…) sous Android (2/3)

Comme expliqué dans mon dernier post, j’ai cherché (et réussi) à afficher des documents MS Office sur une plateforme Android.
L’objet de mon dernier article était de montrer comment afficher des fichiers MS Office « binaires », c’est à dire pré 2007, sous Android. Ici, je vais montrer comment faire la même chose avec des fichiers Office Open Document (c’est à dire MS Office 2007+, docx, pptx, xlsx…)

Je répète le postulat, car il est important: comme mon application ne doit à priori pas permettre l’édition de ces documents, je pars du principe qu’il suffit de convertir ces fichiers MS Office au format HTML pour ensuite les afficher dans une WebView.

La solution pour convertir les fichiers binaires était relativement simple, il suffisait d’ajouter les jars au classpath et d’appeler la bonne méthode. Ici, ça se complique sévèrement. Et ce, pour plusieurs raisons:

Tout d’abord, parce qu’on ne peut PAS ajouter les jars simplement au build path dans un projet Android. En effet, les jars utilisés par la conversion des fichiers OOxml pèsent très lourd: plus de 24 Mo. Voir ici la liste des composants nécessaires et la liste des dépendances de ces composants.

Si on ajoute tous ces jars à notre projet, on obtient un joli « java heap space exception » avant même de pouvoir uploader l’apk sur le téléphone. En fait, pour être plus précis, il s’agit non pas d’un problème de taille des jars, mais surtout du fait que les classes contenues dans ces jars contiennent énormément de méthodes. En effet, la transformation des fichiers .class en un fichier DEX (Dalvik EXecutable) n’autorise pas plus de 64k méthodes.
Pour info/rappel, lorsque vous cliquez sur votre joli bouton vert sur Eclipse, Eclipse appelle un script ANT de commandes du SDK Android, et il va, compiler vos .java en .class (ça tout le monde connait, c’est javac!). Puis, il va référencer toutes les ressources de votre répertoire /res. Puis, il transforme tous vos .class en un seul fichier DEX, qui contient du bytecode lisible par la machine virtuelle Dalvik. C’est la commande dx, il y’a d’ailleurs un fichier dx.bat dans votre répertoire androidSDK/platform-tools. Et le tout (un fichier classes.dex et toutes les ressources xml ou image) est archivé dans un fichier APK.

Et donc, cette « dexisation » n’accepte que 64k méthodes. L’équipe de développement Android a d’ailleurs posté une solution à ce problème. Ce lien est très intéressant, je le recommande chaudement (et puis sinon vous comprendrez difficilement la suite!)

Donc j’ai fait ce qu’ils ont dit: j’ai chargé les .class de tous mes jars additionnels dans plusieurs fichiers .dex que j’ai chargé après, dynamiquement, au lancement de l’application. Tout comme c’est expliqué dans le lien du blog Android. A la seule différence que moi, j’ai créé plusieurs fichiers DEX étant donné que je n’arrivais pas à tout caser dans un seul fichier DEX additionnel.

Voici à quoi ressemblent mes assets dans mon projet explorer:
liste des assets ooxml compliqué

Le répertoire « libsToSpecialDEX » que je n’ai pas affiché en entier contient tous les jars indiqués dans les dépendances de POI pour la partie ooxml. Et même, légèrement plus compliqué que cela, j’ai divisé le jar « schemas-ooxml » en 5 jars différents (un pour chaque package). J’ai donc un jar (et un dex) pour le package word, un jar pour le package excel, etc.
Au final, j’ai donc créé 9 fichiers DEX que je vais charger dynamiquement au lancement de mon application. Pour créer les fichiers DEX, j’ai légèrement modifié le fichier ANT fourni dans l’exemple du blog Android de la façon suivante:


	    
	    
	        
	            
	            	
		            	
		            	
		            	
		            	
		            	
		            	  
	            	
	            	
		            	
		            	
		            	
		            	
		            	
		            	  
	            	
	            	
	            
	            
	                hasCode = false. Skipping...
	            
	        
	    

Notez que je n’ai copié que le target « DEX » et que j’ai tronqué pour n’afficher que les deux premiers fichiers DEX que j’ai créés. (Comme expliqué plus haut, j’ai finalement fait 9 fichiers DEX additionnels!)

Donc, une fois les fichiers DEX créés, il faut les charger dans le classloader de mon application. Cette opération est faite à l’exécution de l’application. Pour des raisons de simplicité, j’ai tout laissé dans le onCreate de mon application. Mais c’est une opération lourde, il faudrait donc la mettre dans un thread séparé!

public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        //si le fichier de démo "SampleSS.xlsx" n'est pas déjà présent sur la SDCard, on le copie
        if(!new File("/sdcard/office/SampleSS.xlsx").exists())
        {
        //la méthode copyFilesToPrivate est la même que celle utilisée dans l'article précédent                                 // pour copier les assets vers la SD
        	copyFilesToPrivate("SampleSS.xlsx");
        }
        //chargement de la classe qui convertit le xlsx en html
        Class cl0 = loadClass( "org.apache.poi.ss.examples.html.ToHtml");
    
        try {
	        //invoquer la méthode main de la classe "ToHtml" avec deux arguments
	        //(merci Java pour le cast obligatoire et non intuitif)
	        main.invoke(o, (Object) new String[] {"/sdcard/office/SampleSS.xlsx", "/sdcard/office/SampleSS.html"});
		} catch (InstantiationException e) {
			TestOfficeAndroidActivity.this.getClassLoader().clearAssertionStatus();
			e.printStackTrace();
		} catch (IllegalAccessException e) {
			TestOfficeAndroidActivity.this.getClassLoader().clearAssertionStatus();
			e.printStackTrace();
		} catch (Exception e) {
			e.printStackTrace();
			TestOfficeAndroidActivity.this.getClassLoader().clearAssertionStatus();
		}
    }

Vous noterez cette fois ci l’invocation de la méthode « main » par réflexion, car, lors de la compilation du projet, on n’a pas de référence aux classes de POI! En effet, celles-ci ne sont chargées qu’à l’exécution, dans la méthode loadClass() que voici:

 private Class loadClass(String className) {
    	// Internal storage where the DexClassLoader writes the optimized dex file to
    	new File("/sdcard/office/opti").mkdirs();
        final File optimizedDexOutputPath = new File("/sdcard/office/opti");

        //the dexloader is given all the jars that we need and loads them into the activity's classloader
		DexClassLoader cl = new DexClassLoader(onary.getAbsolutePath()+":"+secondary.getAbsolutePath(),
                                               optimizedDexOutputPath.getAbsolutePath(),
                                               null,
                                               TestOfficeAndroidActivity.this.getClassLoader());
        Class libProviderClazz = null;
        try {
            // Load the library.
            libProviderClazz = cl.loadClass(className);
        }
        catch(Exception e)
        {e.printStackTrace();}
            
		return libProviderClazz;
	}

Ici, la ligne réellement importante est celle avec le new DexClassLoader. En effet, c’est ici qu’on va lire les fichiers dex (précédemment stockés sur la SD card), les optimiser, et surtout, les charger en mémoire dans le classLoader de notre activité. Cela permettra, par la suite, d’appeler la méthode « main » de la classe « ToHtml ».
Dans l’exemple ci dessus, j’ai tronqué un peu la méthode pour plus de clarté (je rappelle que dans mon projet, ce n’est pas 2 fichiers DEX, mais 9 qu’il a fallu ajouter au classLoader!)

Et là… c’est le drame!
On aurait pu penser que cette méthode marcherait comme ça, mais non! Ici, on obtient un message pas très clair:

W/System.err(370): Caused by: java.lang.RuntimeException: Installation Problem??? Couldn’t load messages: Can’t find resource for bundle ‘org.apache.xmlbeans.impl.regex.message_en_US’, key  »

Après un peu de recherches, je me rends compte qu’il s’agit d’un message d’erreur causé par l’une des dépendances (XmlBeans, en l’occurence) lorsqu’un fichier de ressources n’est pas présent. Mais pourtant, ce fichier, il est bien dans mon jar! Bizarre bizarre… En fait, pas du tout! C’est parfaitement logique. La « dexisation » a pour unique but de transformer les .class en un fichier DEX. Mais si un fichier autre que .class se trouve aussi dans le jar qu’on transforme, il est est ignoré!

Après un petit hack pas très propre (j’ai recompilé XmlBeans en modifiant la ligne qui recherche ce fichier de ressources), un nouveau problème du même genre se pose: il se trouve que dans le jar « ooxml-schemas » de POI, il y’a tout un tas de fichiers « .xsb ». Ceux-ci sont en fait des schémas qui permettent à XmlBeans de générer des classes qui modélisent les objets Office Open. Et ils sont donc fondamentaux au fonctionnement de la partie « ooxml » de POI.

Après un second hack pas très propre (j’ai recompilé ooxml-schemas en spécifiant que les fichiers XSB se trouvaient non pas à la racine du projet, mais dans un répertoire de ma SDcard), c’était presque bon! Plus d’erreur bizarres à cause de fichiers manquants, simplement une bonne vieille erreur ClassNotFoundException: java.awt.Color

Je vous le donne en mille: il a fallu retirer dans toutes les classes de POI toutes les références à java.awt.* car ce package n’existe pas sous Android! Hé oui, le projet d’Apache est prévu pour Java, et non pour Android. C’est d’ailleurs toute la difficulté exprimée dans cet article.
Donc c’était fastidieux, mais pas impossible. Il a suffit de remplacer les références à java.awt.Color par des android.graphics.Color et les java.awt.Dimension par une petite classe à moi qui fait presque tout comme Dimension.

Et là… ça marche!!!

Pour ceux qui voudraient voir tout le code, il est « checkoutable » ici.

Alors bien sur… C’est pas très optimisé tout ça. J’ai tenté, comme pour la version qui marche avec des fichiers binaires (voir post précédent), de supprimer l’inutile. Mais c’est une prise de tête sans fin. D’ailleurs, j’ai demandé aux devs de POI. Ils m’ont bien confirmé qu’il est impossible d’enlever des grosses parties de leurs jars pour n’utiliser que la fonction « extraction au format HTML ».
D’ailleurs, mon APK pèse dans les 8Mo et il décompresse tous les fichiers XSB (soit une taille supplémentaire de 15 Mo) sur la SDCard, ce qui fait donc vraiment beaucoup trop pour une application qui ne fait que de la lecture de fichier OOxml.

Mais au moment de commencer à vraiment optimiser le projet, un cher collègue qui se reconnaîtra m’a fait part d’une solution alternative infiniment plus simple, que je vais vous présenter dans mon prochain post!