Affichage des articles dont le libellé est Android. Afficher tous les articles
Affichage des articles dont le libellé est Android. Afficher tous les articles

mardi 16 janvier 2018

Consider this before updating your app in Google Play

Context

There a lot of things happening when you deploy an update of your app on Google Play... One of the bad experiment I had lately was caused by Kaspersky free antivirus, one of the most popular tool for Android.

Looking at comment from people who updated my Bubble Level app, I discovered that Kaspersky free antivirus detected a threat in the latest update...


Kaspersky free antivirus screenshot

Too late, the app was already deployed and the number of bad comments from users were raising...

Uninstall peak after update

So had I to rollback the app version in production.

The problem

You cannot revert an app version in the Google Play console...

The solution(s)

Rebuild the app from the previous version tag from your VCS. But sometimes, it means downloading Gigs of old SDK, build tools, artefacts you don't care anymore... So here is a simple solution.
  1. Download previous apk from your Google Play publisher account
  2. Download apktool
  3. Decompile the app apk using apktool
  4. Update the apktool.yml to increase version code and version number
  5. Recompress the app apk using apktool
  6. Sign it using jarsigner from java distribution
  7. Align it using zipalign from android sdk distribution
  8. Upload your apk in your Google Play publisher account

Lesson learned

Have Kaspersky scan performed before release !
Enjoy !

Update

I oppenned a ticket using the Kaspersky free antivirus app, everything was solve within two days. Kaspersky updated its antivirus database. My app is no longer considered as a false positive...

vendredi 1 janvier 2016

Pack textures and fonts at build time for your LibGDX project

When working with LibGDX and openGL ES we usually need to provide our application with various assets such as :
  • PNG for textures
  • Fonts (whether BitmapFont or FreeTypeFont) for texts
  • Sounds
  • 3D objects
  • etc...
When designing the application, we need to take care of :
  • Reducing the loading time of the assets by reducing the number of required assets
  • Reducing the rendering time of the required assets by packing assets together 
  • Reducing the integration time of the required assets by handling assets generation as part of the automated build process
A way of doing this is to pack all the required PNG for textures into a single TextureAtlas asset. LibGDX provides a TexturePacker utility for that. Some other third party utilities are usefull to generate BitmapFont that can be loaded by the LibGDX runtime.

As an experimented developper, we should expect regular changes among our application design during the development phase which means we should whether re-generate fonts and re-pack textures everytime or work with an application that is not efficient to load and to render (without packed assets and BitmapFont)... As a lazy developper, we might want all the assle of font and texture generation and packing to be handled automagically by the gradle build process.

Here is how we can achieve this...

The big picture

Okay, so let say we want to build an UI that renders a deck of cards using LibGDX :

Card Deck rendered with resources generated at build time

The UI is made of 52 sprites (one for each card) and a BitmapFont to render the label "LibGDX Card Deck"... Everything is laid out with a simple Table. What we want is to be able to change the finest detail with a little effort. For instance :
  • change the red tone used for hearts and diamonds
  • change the shape, the aspect ratio of the card
  • increase the size of the rank
  • decrease the size of the suite
  • change the position of the rank / suit inside the card
  • change the font used for the label

Project layout

When setting up a new LibGDX project you get something like this :

+ rootProject.projectDir
|--+ android
     |--+ assets
     |--+ build

     |--+ res
     |--+ src
     |--+ build.gradle
|--+ core
     |--+ src
     |--+ build.gradle
|--+ desktop
     |--+ src
     |--+ build.gradle
|--+ html

|--+ ios
|--+ resources
|--+ build.gradle

Where the ${rootProject.projectDir}/android/assets directory holds the assets required by the application (whatever the targeted platform : android, desktop, html or ios). So what we are going to do is to create a ${rootProject.projectDir}/resources directory containing the source files used to generate the required assets at build time. Intermediate temporary files will be generated into the ${rootProject.projectDir}/android/build directory.

Design of the cards

To design our card, we are going to use a SVG card pattern wich will define the overall aspect of the card : its shape and the position of the rank and the suit inside the shape. The SVG file will look something like this :


This SVG card pattern pattern use the custom SVGMASK file format defined by the AndroidSvgDrawable library. This allow us to generate all of the 52 combination of card rank x card suit without the need of 52 SVG files...

Then, we need 4 SVG files for suits (one for each card suit) and 13 SVG files for ranks (one for each card rank from 2 to A). Here are the sample files for Heart and King :

  

That's it, let see how we can now generate all of our 52 card textures at build time...

Generating the card textures

To generate the 52 textures, we will use the AndroidSvgDrawable plugin that now integrates better with LibGDX. First, we need to add the plugin dependency to our buildscript classpath in ${rootProject.projectDir}/build.gradle :

Then, we define a custom task in our ${rootProject.projectDir}/desktop/build.gradle file :

This will take the SVG card pattern file we put inside ${rootProject.projectDir}/resources/svg/mask and generate a PNG file for each SVG suit file x SVG rank file we put into ${rootProject.projectDir}/resources/svg/masked :

${rootProject.projectDir}/resources/svg/masked 

${rootProject.projectDir}/android/build/generated/assets

Generating the BitmapFont and the TextureAtlas


In order to generate the BitmapFont and the TextureAtlas at build time, we will rely on the API provided by LibGDX (we must use at least version 1.7.3-SNAPSHOT in order to be able to generate the BitmapFont as mentionned bellow). We should then add the necessary dependencies to our buildscript classpath in ${rootProject.projectDir}/build.gradle :

Most of the time, LibGDX APIs requires the LibGDX runtime to be loaded first, so we must add a specific task in our ${rootProject.projectDir}/desktop/build.gradle file that will load an headless LibGDX runtime for the build purpose :


Generating the BitmapFont

The BitmapFont will be generated from a TrueType font located in the ${rootProject.projectDir}/resources/font directory. A custom task in our ${rootProject.projectDir}/desktop/build.gradle file will generate the BitmapFont for us with the desired parameter :

A BitmapFont is made of 2 files :
  • a .fnt file that will be output in the ${rootProject.projectDir}/android/assets directory directly
  • a .png file that will be output in the ${rootProject.projectDir}/android/build/generated/assets directory because we want to merge it later with the others texture inside our final TextureAtlas...
You can refer to the LibGDX freetype documentation to customize the task to generate the desired font appearence. You can also use a more complexe task to generate BitmapFont of different size, colors, ...

Generating the Atlas

As all the required PNG are now generated inside the ${rootProject.projectDir}/android/build/generated/assets directory, we are now ready to pack them all inside a single TextureAtlas. Once again, we use a custom task in our ${rootProject.projectDir}/desktop/build.gradle file that will do the job for us :

And here is the final skin.png TextureAtlas generated with a default size of 1024 x 1024 that is available in the ${rootProject.projectDir}/android/assets directory along with its skin.atlas descriptor :

skin.png
Once again you can refer to the LibGDX Texture packer documentation for more packing options and a finest control over the generated atlas. Note that LibGDX Texture packer support packing of NinePatch drawable that could be generated at build time with the AndroidSvgDrawable plugin.

Let's build it

In order to chain all of those tasks together, we bind the compileJava task from the java gradle plugin to our custom generateATLAS task :

Here we are, everything is ready, we can just sit and watch gradle work for us :

gradlew desktop:run

:core:compileJava
:core:processResources UP-TO-DATE
:core:classes
:core:jar
:desktop:initHeadlessLibGDX UP-TO-DATE
:desktop:generateFONT
:desktop:generatePNG
:desktop:generateATLAS
assets
Packing.........
Writing 1024x1024: android\assets\skin.png
:desktop:compileJava
:desktop:processResources UP-TO-DATE
:desktop:classes
:desktop:run

BUILD SUCCESSFUL

Total time: 11.758 secs

And if we build it once again, the Gradle magic happens, nothing is re-generated or re-packed because everything is UP-TO-DATE :

gradlew desktop:run

:core:compileJava UP-TO-DATE
:core:processResources UP-TO-DATE
:core:classes UP-TO-DATE
:core:jar UP-TO-DATE
:desktop:initHeadlessLibGDX UP-TO-DATE
:desktop:generateFONT UP-TO-DATE
:desktop:generatePNG UP-TO-DATE
:desktop:generateATLAS UP-TO-DATE
:desktop:compileJava UP-TO-DATE
:desktop:processResources UP-TO-DATE
:desktop:classes UP-TO-DATE
:desktop:run

BUILD SUCCESSFUL

Loading and rendering the assets

Loading the assets requires only a single atlas to be loaded by the application while rendering all the cards and the label will bind a single OpenGL ES texture :

Want to be more optimal ? We can't !

Changing a detail

One more thing ! It's now possible to change the card layout for all our cards by changing only one file. If I modify the SVG card pattern to have the suit in the center of the card and the rank in the upper left corner and the lower right corner as well :

When building the project once again :

gradlew desktop:run

:core:compileJava
:core:processResources UP-TO-DATE
:core:classes
:core:jar
:desktop:initHeadlessLibGDX UP-TO-DATE
:desktop:generateFONT UP-TO-DATE
:desktop:generatePNG
:desktop:generateATLAS
assets
Packing.........
Writing 1024x1024: android\assets\skin.png
:desktop:compileJava
:desktop:processResources UP-TO-DATE
:desktop:classes
:desktop:run

BUILD SUCCESSFUL

Total time: 11.758 secs

Then, the layout has been changed for all of the 52 cards :


Simple isn't it ?

You can find the whole LibGDX project in the sample directory of the AndroisSvgDrawable plugin GitHub project.

samedi 9 mars 2013

Generate Android image resources from SVG with Maven

I recently wrote the AndroidGenDrawable Maven plugin for Android that allow you to generate density specific PNG drawable from SVG (scalable vector graphics) at build time for your Android projects. Here is a brief presentation.

Fragmentation

Fragmentation is an actual problem when speaking of Android development. Specially when programmers need to maintain density specific raster graphics image (bitmaps) to target all of the possible Android device screens. The very first example is the Launcher icon of an Android application that should be resized depending on the generalized screen density (see link) :

ldpi generalized density
mdpi generalized density
hdpi generalized density
xhdpi generalized density

androidgendrawable-maven-plugin

The androidgendrawable maven plugin allow you to work on a unique SVG version of your assets and to generate the density specific PNG versions required by the Android runtime at build time. The SVG artwork are automatically scaled and transcoded to PNG for you during the maven build. The plugin rely on apache batik to transcode the SVG to PNG. You just have to add the plugin declaration and its configuration to your pom.xml :
<plugin>
 <groupId>fr.avianey.modjo</groupId>
 <artifactId>androidgendrawable-maven-plugin</artifactId>
 <version>1.0-SNAPSHOT</version>
 <configuration>
  <from>${project.basedir}/svg</from>
  <to>${project.basedir}/res</to>
  <rename>
   <level>icon</level>
  </rename>
  <createMissingDirectories>true</createMissingDirectories>
  <targetedDensities>
   <density>ldpi</density>
   <density>mdpi</density>
   <density>hdpi</density>
   <density>xhdpi</density>
  </targetedDensities>
  <fallbackDensity>mdpi</fallbackDensity>
  <skipNoDpi>true</skipNoDpi>
 </configuration>
 <executions>
  <execution>
   <phase>initialize</phase>
   <goals>
    <goal>gen</goal>
   </goals>
  </execution>
 </executions>
<plugin>
Required resources are then generated during the initialize phase of the Maven build :
[INFO] ------------------------------------------------------------------------
[INFO] Building level 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] --- androidgendrawable-maven-plugin:1.0-SNAPSHOT:gen (default) @ level ---
[INFO] Transcoding level-xhdpi.svg to drawable
[INFO] Transcoding level-xhdpi.svg to drawable-hdpi
[INFO] Transcoding level-xhdpi.svg to drawable-ldpi
[INFO] Transcoding level-xhdpi.svg to drawable-xhdpi
You can fork the plugin on github here and read the documentation on the associated github page here.

mardi 5 février 2013

The Facebook Android API v3.0 on Maven

For those of you that might be interrested, I just created a Maven repository that hosts my Maven port of the Facebook Android API in its latest v3.0 version. You just need to reference the GitHub pages hosted repository in you Android Maven project pom.xml :
<repositories>  
  ...  
  <repository>  
    <id>The mavenized Facebook Android API</id>  
    <url>http://avianey.github.com/facebook-api-android-maven/</url>  
  </repository>  
</repositories>
...and to import the facebook-android-api as a dependency :
<dependencies>
  ...
  <dependency>
    <groupId>com.github.avianey</groupId>
    <artifactId>facebook-android-api</artifactId>
    <version>3.0.0</version>
    <type>apklib</type>
  </dependency>
</dependencies>
I picked the groupId to avoid conflict with other ports one might find on the Internet. As usual, you can fork this mavenized Facebook Android API on GitHub.

jeudi 1 novembre 2012

Un simulateur de crédit à emporter

Aujourd'hui je vous fait partager un petit développement personnel effectué afin de tester les possibilités offertes par le framework jQuery mobile. Il s'agit d'un petit utilitaire permettant de simuler un emprunt quelconque (prêt immobilier, crédit à la consommation, ...) en précisant le montant et la durée d'emprunt, le taux d'intérêt et d'éventuelles charges additionnelles (prîmes d'assurances ou autres).

Le résultat est présenté de manière synthétique sous forme de popup avec le montant de la mensualité, le total des intérêts, le taux effectif global et le coût total du crédit. Une vue détaillée par mensualité permet de visualiser le détail des intérêts, du montant remboursé et du montant restant à rembourser. Voici le résultat :


Vous pouvez également faire le test sur votre smartphone ou tablette

lundi 5 décembre 2011

Carte Google Map plein écran avec jQuery Mobile

jQuery mobile est arrivé en version finale il y a quelques jours. Voyons avec quelle simplicité jQuery mobile nous permet de créer des Mashups à base de Google Map API et portable sur n'importe quel terminal mobile supporté par jQuery mobile.

Récupération du template HTML5

jQuery mobile propose des templates de pages permettant de démarrer rapidement. Nous utiliserons le template suivant, légèrement modifié afin de pouvoir utiliser les icones personnalisées map icons de Nicolas Mollet disponible sous licence Creative Commons 3.0 BY-SA :
<!DOCTYPE html> 
<html> 
    <head> 
    
    <title>Fullscreen Google map with jQuery mobile</title>
    
    <meta name="viewport" content="width=device-width, initial-scale=1"> 
    <link rel="stylesheet" href="http://code.jquery.com/mobile/1.0/jquery.mobile-1.0.min.css" />
    <script src="http://code.jquery.com/jquery-1.6.4.min.js"></script>
    <script src="http://code.jquery.com/mobile/1.0/jquery.mobile-1.0.min.js"></script>
    
</head> 

<body> 

<div data-role="page" id="map" data-theme="a">

    <div data-role="header" id="header" data-position="fixed">
        <h1>Fullscreen Google map with jQuery mobile</h1>
    </div>

    <div data-role="content" id="content">    
        <!-- carte google map -->
    </div>
    
    <div data-role="footer" data-position="fixed">        
        <div data-role="navbar" data-iconpos="top">
            <ul>
                <li>
                    <a class="ui-btn ui-btn-icon-top ui-btn-up-a ui-btn-active" 
        data-icon="information" href="#" >
                        Infos
                    </a>
                </li>
                <li>
                    <a class="ui-btn ui-btn-icon-top ui-btn-up-a" data-icon="favorite" href="#">
                        Favoris
                    </a>
                </li>
                <li>
                    <a class="ui-btn ui-btn-icon-top ui-btn-up-a" data-icon="radar" href="#">
                        Radars
                    </a>
                </li>
            </ul>
        </div>
    </div>
    
</div>

</body>
</html>
Les icônes personnalisées déposées dans le répertoire static/images sont mises en place avec le CSS personnalisé ci dessous :
<style type="text/css">
    .ui-icon.ui-icon-favorite, .ui-icon.ui-icon-radar, 
    .ui-icon.ui-icon-information, .ui-icon.ui-icon-evenement {
        margin-top: -16px;
        margin-left: -19px;
        width: 37px;
        height: 37px;
        box-shadow: none;
    }
    .ui-icon.ui-icon-favorite {
        background: url(static/images/favorite.png) top center;
    }
    .ui-icon.ui-icon-radar {
        background: url(static/images/radar.png) top center;
    }
    .ui-icon.ui-icon-information {
        background: url(static/images/information.png) top center;
    }
    .ui-icon.ui-icon-evenement {
        background: url(static/images/evenement.png) top center;
    }
</style>
Ce qui nous donne le rendu de page suivant, avec sa barre de navigation style iPhone contenant nos icônes personnalisées :

Chargement de la carte Google en plain écran

L'étape suivante consiste à charger notre carte et à dimensionner son conteneur de façon à ce qu'il occupe la totalité de l'écran. Le padding du corps de la page qui contient le conteneur de la carte est positionné à 0 :
<div data-role="content" id="content" style="padding:0">
    <div id="map_canvas"></div>
</div>
L'événement pageshow de la page est utilisé pour de déterminer les dimensions maximales de la carte afin qu'elle occupe tout l'espace disponible. Cet évènement permet de s'assurer que tous les widgets jQuery mobiles ont été initialisés par le framework :
<script type="text/javascript">
    function initialize() {
        var latlng = new google.maps.LatLng(45.7452, 4.8418);
        var myOptions = {
            zoom: 14,
            center: latlng,
            mapTypeId: google.maps.MapTypeId.ROADMAP
        };
        var map = new google.maps.Map(
                                document.getElementById("map_canvas"), 
                                myOptions);

    }
    
    $('#map').live('pageshow', function(event) {
        $("#map_canvas").width($(document).width());
        $("#map_canvas").height(
            $(window).height() 
            - $("div.ui-footer").outerHeight() 
            - $("div.ui-header").outerHeight()
        );
        initialize();
    });
</script>
Et voila, comme diraient nos amis anglophones !

mercredi 23 mars 2011

L'Amazon Appstore démarre fort

Pour ceux qui serait passé à côté de l'info, Amazon a lancé hier sa place de marché d'applications Android sous l'appelation Amazon Appstore for Android. Aux côtés des Androidpit, Appbrain et autres pionniers qui tentent de s'adjuger une part du gâteau, Amazon à su partir à point en s'offrant l'exclusivité de la dernière version d'Angry birds : Angry birds RIO. L'Amazon Appstore pour Android à été l'application la plus téléchargée aux états unis et ce, sans même être présente sur l'Android market :


Applications les plus téléchargées aux états unis
le 22 mars 2010

Reste maintenant à savoir si cette place de marché saura satisfaire les développeur et attirer des utilisateurs et clients sur le long terme... Affaire à suivre.


Ces statistiques ont été agrégée au moyen de Google Analytics et de l'application Instant Uninstaller disponible sur l'Android market :

mardi 14 décembre 2010

L'application Niveau à Bulle devient open source !

Aujourd'hui est un jour un peu particulier pour moi puisque c'est la première fois que je publie une application en licence open source. Cela fait déjà plusieurs années que j'utilise dans le cadre professionnel ou personnel des applications ou librairies java open sources (principalement sous Licence Apache ou LGPL). Ces applications ou librairies m'ont permis d'avancer sur de nombreux sujets... et il me tenait à coeur depuis un bout de temps de participer au mouvement. C'est donc avec une grande joie que je propose le code d'une de mes applications Android déjà téléchargée plus d'un million de fois :

Niveau à Bulle

De nombreuses licences open source existent mais je n'ai pas hésité longtemps pour m'orienter vers la GNU General Public License v3. Cette licence m'a semblé être la plus adapté dans le cadre de la publication d'une application complète, n'étant pas destinée à être intégrée dans une application au périmètre plus étendu. S'il avait été question d'une librairie, j'aurais sans hésitation opté pour l'Apache licence 2. Entre les deux, la GNU Lesser General Public License me paraît être destinée à la publication d'outils finis intégrables dans une application au périmètre étendu. Mais cela demeure une réflexion purement personnelle. Pour ceux d'entre vous qui sont intéressés par le code source de l'application, ça se passe ici : projets androgames. De nombreux cas d'utilisation Android sont couverts :

  • Utilisation des senseurs d'orientation et d'accélération
  • Utilisation de l'api Android de gestion des préférences
  • Persistance de données
  • Gestion d'animations 2D via une SurfaceView
  • Utilisations d'Intent
  • Effets sonores
  • Utilisation d'une police de caractères alternative
  • Gestion de la segmentation
  • Utilisation de l'API de déclaration XML de formes géométriques (dessin vectoriel)
  • Support multilingue
  • ...


Pour ceux qui ne connaissent pas encore l'application, voici quelques copies d'écran. Et si vous voulez la tester en conditions réelles, rendez-vous sur l'Android Market.



lundi 13 décembre 2010

Automatisation des livraisons pour l'Android Market

Dans la dernière version de son SDK Android, Google a intégré l'utilitaire ProGuard d'obfuscation et d'optimisation de code. Cette intégration par Google facilite l'automatisation du processus de génération d'applications optimisées à destination de l'Android market. Voyons comment il nous est possible de générer deux apk (Android Package) signés, obfusqués, optimisés et aux fonctionnalités différentes en ne maintenant qu'une unique version du code source et un script Ant.

Ennoncé du problème

Prenons l'exemple de l'application Niveau à bulle. Cette application est présente en deux versions sur l'Android market. Une version gratuite, sans publicité et une version payante permettant aux personnes qui le souhaite de faire un petit don au développer ;-)

La seule différence entre les deux applications est la présence ou non des liens "Donate" et "My Applications" au niveau des préférences.



Afin de ne pas complexifier le développement, nous voudrions ne disposer que d'une unique version des sources à maintenir et d'un moyen pour générer nos deux apk aussi rapidement qui si nous n'en avions qu'un !

Génération des scripts

Assurez-vous dans un premier temps de disposez de la version 8 du SDK Android. Pour générer les scripts initiaux permettant d'automatiser le build de l'application, il faut lancer la commande suivante à partir du répertoire tools du SDK Android :

cd %ANDROID_HOME%/tools
android update project --path ./Level


Vous vous retrouvez ainsi avec trois fichiers supplémentaires à la racine de votre projet :
build.xml
Script Ant permettant de générer un livrable Android non signé, non obfuscé et non optimisé
local.properties
Fichier de configuration du script Ant spécifique à votre ordinateur et permettant d'indiquer les chemins vers votre keystore, le répertoire d'installation du SDK Android, ... Ce fichier sera généralement spécifique à chaque poste de développement, et non géré en gestion de configuration.
proguard.cfg
Fichier de configuration spécifique à ProGuard et adapté aux applications Android.

Pour lancer la construction d'un livrable non signé, non obfuscé, il suffit de lancer la tache "release" du script build.xml :

ant release

Pour générer un livrable signé, non obfuscé, il suffit d'ajouter deux propriétés au fichier de configuration local.properties (puis relancer la tache "release" de Ant) :

key.store=/chemin/vers/mon/keystore.ks
key.alias=alias


Enfin, pour générer un livrable signé et obfuscé, il ne reste plus qu'à configurer l'utilisation de ProGuard par le SDK Android. Pour cela nous allons créer un fichier build.properties gérable en gestion de configuration et contenant la déclaration du fichier de configuration ProGuard (puis relancer la tache "release" de Ant) :

proguard.config=proguard.cfg

Il est important de re-tester son application de manière minutieuse lorsque l'on souhaite la publier en version obfuscé. En effet, Android permet de déclarer l'appel de code java depuis des fichiers XML. C'est notamment le cas du fichier AndroidManifest.xml. D'une manière générale, le fichier de configuration proguard.cfg généré pour nous par le SDK préserve le code qui est référencé par le fichier AndroidManifest.xml. Ce n'est en revenche pas le cas des click listeners introduits en version 1.6 d'Android. Si vous déclarer le click listener d'un Button dans un fichier de layout, la configuration ProGuard ne préservera pas la méthode appelée dans l'activité utilisant le layout :
<button android:onclick="monClickListener"/>
Si vous souhaitez conserver vos click listeners, vous devrez vous plonger dans la documentation ProGuard et modifier le fichier proguard.cfg.

Adaptation du code

Nous allons maintenant nous intéresser au moyen de maintenir deux versions différentes d'une application au moyen d'un unique code source. L'exemple étant simple, la solution le sera également Afin de ne conserver les préférences "Donate" et "My Applications" que pour l'application gratuite, nous allons modifier la PreferenceActivity LevelPreferences.java en ajoutant des conditions vraies ou fausses autour du code qui distingue les deux versions :
package net.androgames.level;

import net.androgames.level.config.DisplayType;
import net.androgames.level.config.Provider;
import net.androgames.level.config.Viscosity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.ActivityNotFoundException;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.preference.CheckBoxPreference;
import android.preference.Preference;
import android.preference.PreferenceActivity;
import android.preference.PreferenceCategory;
import android.preference.PreferenceManager;
import android.preference.Preference.OnPreferenceChangeListener;
import android.preference.Preference.OnPreferenceClickListener;

public class LevelPreferences extends PreferenceActivity implements OnPreferenceChangeListener {

    public static final String KEY_SHOW_ANGLE           = "preference_show_angle";
    public static final String KEY_DISPLAY_TYPE         = "preference_display_type";
    public static final String KEY_SOUND                = "preference_sound";
    public static final String KEY_LOCK                 = "preference_lock";
    public static final String KEY_LOCK_LOCKED          = "preference_lock_locked";
    public static final String KEY_LOCK_ORIENTATION     = "preference_lock_orientation";
    public static final String KEY_APPS                 = "preference_apps";
    public static final String KEY_DONATE               = "preference_donate";
    public static final String KEY_SENSOR               = "preference_sensor";
    public static final String KEY_VISCOSITY            = "preference_viscosity";
    public static final String KEY_ECONOMY              = "preference_economy";

    private static final String PUB_APPS     = "market://search?q=pub:\"Antoine Vianey\"";
    private static final String PUB_DONATE   = "market://details?id=net.androgames.level.donate";
    
    private static final int DIALOG_CALIBRATE_AGAIN = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        addPreferencesFromResource(R.xml.preferences);
        if (true) {
            PreferenceCategory appsCategory = new PreferenceCategory(this);
            appsCategory.setTitle(R.string.preference_apps_category);
            Preference appsPreference = new Preference(this);
            appsPreference.setTitle(R.string.preference_apps);
            appsPreference.setSummary(R.string.preference_apps_summary);
            appsPreference.setKey(KEY_APPS);
            Preference donatePreference = new Preference(this);
            donatePreference.setTitle(R.string.preference_donate);
            donatePreference.setSummary(R.string.preference_donate_summary);
            donatePreference.setKey(KEY_DONATE);
            getPreferenceScreen().addPreference(appsCategory);
            appsCategory.addPreference(donatePreference);
            appsCategory.addPreference(appsPreference);
        }
    }

    public void onResume() {
        super.onResume();
        // enregistrement des listerners
        findPreference(KEY_DISPLAY_TYPE).setOnPreferenceChangeListener(this);
        findPreference(KEY_SENSOR).setOnPreferenceChangeListener(this);
        findPreference(KEY_VISCOSITY).setOnPreferenceChangeListener(this);
        findPreference(KEY_ECONOMY).setOnPreferenceChangeListener(this);
        // mise a jour de l'affichage
        SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
        findPreference(KEY_DISPLAY_TYPE).setSummary(DisplayType.valueOf(
                prefs.getString(LevelPreferences.KEY_DISPLAY_TYPE, "ANGLE")).getSummary()); 
        findPreference(KEY_SENSOR).setSummary(Provider.valueOf(
                prefs.getString(LevelPreferences.KEY_SENSOR, "ORIENTATION")).getSummary());
        findPreference(KEY_VISCOSITY).setSummary(Viscosity.valueOf(
                prefs.getString(LevelPreferences.KEY_VISCOSITY, "MEDIUM")).getSummary());
        findPreference(KEY_VISCOSITY).setEnabled(
                !((CheckBoxPreference) findPreference(KEY_ECONOMY)).isChecked());
        if (true) {
            // lancement du market
            findPreference(KEY_APPS).setOnPreferenceClickListener(new OnPreferenceClickListener() {
                @Override
                public boolean onPreferenceClick(Preference preference) {
                    Intent intent = new Intent(Intent.ACTION_VIEW);
                    intent.setData(Uri.parse(PUB_APPS));
                    LevelPreferences.this.startActivity(intent);
                    return true;
                }
            });
            findPreference(KEY_DONATE).setOnPreferenceClickListener(new OnPreferenceClickListener() {
                @Override
                public boolean onPreferenceClick(Preference preference) {
                    Intent intent = new Intent(Intent.ACTION_VIEW);
                    intent.setData(Uri.parse(PUB_DONATE));
                    try {
                        LevelPreferences.this.startActivity(intent);
                    } catch (ActivityNotFoundException anfe) {}
                    return true;
                }
            });
        }
    }

    @Override
    public boolean onPreferenceChange(Preference preference, Object newValue) {
        String key = preference.getKey();
        if (KEY_DISPLAY_TYPE.equals(key)) {
            preference.setSummary(DisplayType.valueOf((String) newValue).getSummary());
        } else if (KEY_SENSOR.equals(key)) {
            preference.setSummary(Provider.valueOf((String) newValue).getSummary());
            showDialog(DIALOG_CALIBRATE_AGAIN);
        } else if (KEY_VISCOSITY.equals(key)) {
            preference.setSummary(Viscosity.valueOf((String) newValue).getSummary());
        } else if (KEY_ECONOMY.equals(key)) {
            findPreference(KEY_VISCOSITY).setEnabled(!((Boolean) newValue));
        }
        return true;
    }
    
    protected Dialog onCreateDialog(int id) {
        Dialog dialog;
        switch(id) {
            case DIALOG_CALIBRATE_AGAIN:
                AlertDialog.Builder builder = new AlertDialog.Builder(this);
                builder.setTitle(R.string.calibrate_again_title)
                        .setIcon(android.R.drawable.ic_dialog_alert)
                        .setCancelable(true)
                           .setNegativeButton(R.string.ok, new DialogInterface.OnClickListener() {
                               public void onClick(DialogInterface dialog, int id) {
                                   dialog.dismiss();
                               }
                           })
                           .setMessage(R.string.calibrate_again_message);
                dialog = builder.create();
                break;
            default:
                dialog = null;
        }
        return dialog;
    }
    
}
Les directives if (true) {...} et if (false) {...} seront automatiquement optimisées par ProGuard lors de la phase d'optimisation du code : le code inaccessible ou inutilisé est automatiquement supprimé par l'outil.

Il ne reste plus qu'à créer un script Ant dont le but sera de remplacer la directive if (true) par if (false) et de lancer la tache ant release sur chacune des deux versions.

Automatisation des livraisons

Chaque application de l'Android market doit déclarer un nom de package unique dans son fichier AndroidManifest.xml. Pour publier deux versions différentes, il faut donc modifier le nom de package utilisé. Dans notre cas, nous en utiliserons deux :
  • net.androgames.level : pour la version gratuite
  • net.androgames.level.donate : pour la version payante 
Le renommage du package n'est pas sans conséquence, puisqu'il implique de déplacer l'activité principale (et autres activités, receivers, services, ...) dans le nouveau package déclaré, et de modifier la déclaration des packages et des imports pour ces classes et les classes référentes.

Imaginons que nous maintenions la version gratuite des sources avec l'utilisation des préférences supplémentaires. Dans un premier temps, nous allons dupliquer la totalité du projet dans un répertoire temporaire de travail :
<mkdir dir="${temp.dir}" />
<copy todir="${temp.dir}">
 <fileset dir=".">
     <exclude name="**/${temp.dir}/**" />
 </fileset>
</copy>
Il va ensuite falloir effectuer les modifications nécessaires sur le nom des packages, les imports et remplacer  la directive if (true) par if (false) :
<!-- debut des modifications specifiques -->

<replace file="${temp.dir}/AndroidManifest.xml" token="net.androgames.level"
            value="net.androgames.level.donate"/>

<replace dir="${temp.dir}" value="net.androgames.level.donate.R">
    <include name="**/*.java"/>
    <replacetoken><![CDATA[net.androgames.level.R]]></replacetoken>
</replace>

<replace dir="${temp.dir}" value="net.androgames.level.donate.Level">
    <include name="**/*.java"/>
    <replacetoken><![CDATA[net.androgames.level.Level]]></replacetoken>
</replace>

<move todir="${temp.dir}/src/net/androgames/level/donate">
    <fileset dir="${temp.dir}/src/net/androgames/level">
        <include name="*.java"/>
    </fileset>
</move>

<replace dir="${temp.dir}" value="package net.androgames.level.donate;">
    <include name="**/*.java"/>
    <replacetoken><![CDATA[package net.androgames.level;]]></replacetoken>
</replace>

<replace file="${temp.dir}/src/net/androgames/level/donate/LevelPreferences.java">
      <replacetoken><![CDATA[if (true) {]]></replacetoken>
      <replacevalue><![CDATA[if (false) {]]></replacevalue>
</replace>

<!-- fin des modifications specifiques -->
Une fois les sources modifiées dans le répertoire temporaire, il peut être nécessaire de supprimer le répertoire gen contenant les fichiers java générées par le SDK afin que ceux-ci soient regénérés lors de la construction des livrables. Si votre version alternative contient des ressources différentes (strings, arrays, attrs, ...), cette régénération est indispensable pour éviter de nombreuses RuntimeException.

Il ne reste plus qu'à mettre le tout en musique dans un script Ant :
<?xml version="1.0" encoding="iso-8859-1"?>
<project name="Livraison Level" default="all">

    <property file="make.properties" />

    <target name="all" 
        description="Package les 2 versions" 
        depends="prepare-livraison, do-original, do-modified, clean"/>
    
    <target name="prepare-livraison" depends="clean">
        <mkdir dir="${temp.dir}" />
        <copy todir="${temp.dir}">
            <fileset dir=".">
                <exclude name="**/${temp.dir}/**" />
            </fileset>
        </copy>
        
        <replace file="${temp.dir}/build.xml" token="${original.name}" value="${modified.name}"/>

        <!-- debut des modifications specifiques -->
        
        <replace file="${temp.dir}/AndroidManifest.xml" token="net.androgames.level"
                    value="net.androgames.level.donate"/>
        
        <replace dir="${temp.dir}" value="net.androgames.level.donate.R">
            <include name="**/*.java"/>
            <replacetoken><![CDATA[net.androgames.level.R]]></replacetoken>
        </replace>
        
        <replace dir="${temp.dir}" value="net.androgames.level.donate.Level">
            <include name="**/*.java"/>
            <replacetoken><![CDATA[net.androgames.level.Level]]></replacetoken>
        </replace>
        
        <move todir="${temp.dir}/src/net/androgames/level/donate">
            <fileset dir="${temp.dir}/src/net/androgames/level">
                <include name="*.java"/>
            </fileset>
        </move>
        
        <replace dir="${temp.dir}" value="package net.androgames.level.donate;">
            <include name="**/*.java"/>
            <replacetoken><![CDATA[package net.androgames.level;]]></replacetoken>
        </replace>
        
        <replace file="${temp.dir}/src/net/androgames/level/donate/LevelPreferences.java">
              <replacetoken><![CDATA[if (true) {]]></replacetoken>
              <replacevalue><![CDATA[if (false) {]]></replacevalue>
        </replace>
        
        <!-- fin des modifications specifiques -->
        
    </target>
    
    <target name="clean">
        <delete dir="${temp.dir}"/>
    </target>

    <target name="do-original">
        <echo message="Création de la version originale" />
        <ant dir="." antfile="build.xml" target="release"/>
    </target>
    
    <target name="do-modified">
        <echo message="Création de la version modifiee" />
        <delete dir="${temp.dir}/gen" />
        <ant dir="${temp.dir}" antfile="build.xml" target="release"/>
        <move file="${temp.dir}/bin/${modified.name}-release.apk" todir="bin"/>
    </target>

</project>
Avec son fichier de configuration :

original.name=Level
modified.name=Level-donate
temp.dir=tmp


Vous vous retrouvez au final avec les deux apk signées, obfuscées et optimisées dans le répertoire bin à la racine du répertoire projet de l'application :

dimanche 21 novembre 2010

Configurer son mobile Android en debug sous Ubuntu

Après de nombreuses bidouilles pour faire reconnaître mon téléphone mobile Android en mode debug sous Ubuntu, voici un petit step by steb qui devrait vous permettre de tester vos applications Android depuis Eclipse. La méthode a fonctionnée avec mon Samsung Galaxy S i9000. Elle devrait également marcher avec d'autres téléphones (HTC Hero, HTC Desire, Sony Xperia, Motorola Droid, ...) si vous n'arrivez pas à le faire reconnaitre par l'ADB :

user@ubuntu:$ ./adb devices
List of devices attached
???????????? no permissions


La commande ./adb s'exécute depuis le répertoire d'installation du SDK Android.

La première étape consiste à récupérer le Vendor ID du périphérique USB correspondant à votre téléphone. Utilisez pour cela la commande usb-device :

user@ubuntu:$ usb-devices

T: Bus=02 Lev=02 Prnt=02 Port=02 Cnt=01 Dev#= 4 Spd=480 MxCh= 0
D: Ver= 2.00 Cls=02(commc) Sub=00 Prot=00 MxPS=64 #Cfgs= 1
P: Vendor=04e8 ProdID=681c Rev=04.00
S: Manufacturer=SAMSUNG
S: Product=SAMSUNG_Android
S: SerialNumber=1000239e45c7
C: #Ifs= 4 Cfg#= 3 Atr=c0 MxPwr=96mA
I: If#= 0 Alt= 0 #EPs= 1 Cls=02(commc) Sub=02 Prot=01 Driver=cdc_acm
I: If#= 1 Alt= 0 #EPs= 2 Cls=0a(data ) Sub=00 Prot=00 Driver=cdc_acm
I: If#= 2 Alt= 0 #EPs= 2 Cls=08(stor.) Sub=06 Prot=50 Driver=usb-storage
I: If#= 3 Alt= 0 #EPs= 2 Cls=ff(vend.) Sub=42 Prot=01 Driver=usbfs


Le code hexa 04e8 est à renseigner dans un fichier 51-android.rules sous /etc/udev/rules.d. Les fichier doit être créé avec les droits root :

user@ubuntu:$ sudo gedit /etc/udev/rules.d/51-android.rules

Y inscrire la ligne :

SUBSYSTEM=="usb", SYSFS{idVendor}=="04e8", MODE="0666"

L'idVendor correspond ici au code hexa récupéré pour le Samsung Galaxy S. Il ne reste ensuite plus qu'à redémarrer l'ADB :

user@ubuntu:$ sudo ./adb kill-server
user@ubuntu:$ sudo ./adb start-server
* daemon not running. starting it now on port 5037 *
* daemon started successfully *
user@ubuntu:$ ./adb devices
List of devices attached
1000239e45c7 device


Le téléphone est correctement reconnu. Les applications peuvent ainsi directement être lancées depuis Eclipse sur le téléphone. Bon développement !

mercredi 20 octobre 2010

Android JAX-RS Client : partie cliente avec Gson

Dans un post précédent, nous avons vu comment il était possible d'exposer des services JAX-RS JSON grâce au Google appEngine et aux API Java Jersey et Jackson. Nous allons cette fois-ci aborder la partie cliente de ces services sous Android dont voici une capture d'écran :

Téléchargement et installation des outils

Pour reproduire ce tutorial, vous aurez besoin des outils et API suivants :
  • Eclipse : le célèbre IDE
  • SDK Android : pour développer des applications Android avec Eclipse
  • Gson : API de sérialisation / désérialisation made in Google
Une fois téléchargés et installés, créez un nouvel Android Project dans votre workspace Eclipse :


Ajoutez un répertoire /lib à la racine du projet et importez-y la librairie gson.jar. Ajoutez également la dépendance au classpath du projet Eclipse.


Afin d'autoriser notre application à effectuer des appels réseaux via Internet, il faut ajouter au fichier AndroidManifest.xml la permission android.permission.INTERNET :
<?xml version="1.0" encoding="utf-8"?>
<manifest 
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="net.androgames.blog.sample.rest.client"
    android:versionCode="1"
    android:versionName="1.0">
      
    <application 
        android:icon="@drawable/icon" 
        android:label="@string/app_name">
    
        <activity 
            android:name=".UserConsumer"
            android:label="@string/app_name"
            android:screenOrientation="portrait">
                  
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            
        </activity>

    </application>
    
    <uses-sdk android:minSdkVersion="4" />
    
    <uses-permission android:name="android.permission.INTERNET" />

</manifest>

Mise en place de l'interface utilisateur

Notre application sera décomposée en deux parties. L'une permettra la création, la mise à jour et la suppression d'utilisateurs tandis que l'autre affichera la liste de tous les utilisateurs avec leur prénom, nom et id :


Le layout utilisé pour le rendu d'un utilisateur de la liste est définit par le fichier user.xml du répertoire res/layout :
<?xml version="1.0" encoding="UTF-8"?>
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="?android:attr/listPreferredItemHeight"
    android:padding="6dip">
    
    <ImageView
        android:src="@drawable/user"
        android:layout_width="48dip"
        android:layout_height="48dip"
        android:layout_marginRight="6dip" />

    <LinearLayout
        android:orientation="vertical"
        android:layout_width="0dip"
        android:layout_weight="1"
        android:layout_height="fill_parent">

        <TextView
            android:id="@+id/name"
            android:textAppearance="?android:textAppearanceMedium"
            android:layout_width="fill_parent"
            android:layout_height="0dip"
            android:layout_weight="1"
            android:ellipsize="marquee"
            android:gravity="center_vertical" />

        <LinearLayout
            android:orientation="horizontal"
            android:layout_width="fill_parent"
            android:layout_height="0dip"
            android:layout_weight="1" >

            <TextView
                android:textAppearance="?android:textAppearanceSmall"
                android:textColor="#FFCC3333"
                android:textStyle="bold"
                android:layout_marginRight="6dip"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="ID :"
                android:ellipsize="marquee" />
            
            <TextView
                android:id="@+id/id"
                android:textAppearance="?android:textAppearanceSmall"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginRight="6dip"
                android:singleLine="true"
                android:ellipsize="marquee" />
            
        </LinearLayout>
            
    </LinearLayout>

</LinearLayout>
Le formulaire permettant la mise à jour ou la suppression d'un utilisateur existant, ainsi que la création de nouveaux utilisateurs se présentera sous les formes suivantes :



Les actions réalisables sont au nombre de 5 :
  1. Créer un nouvel utilisateur
  2. Enregistrer les modifications apportées au nom ou au prénom d'un utilisateur
  3. Supprimer un utilisateur
  4. Annuler l'édition en cours d'un utilisateur, le formulaire est alors remis à zéro
  5. En cliquant sur un utilisateur de la la liste, le formulaire se met à jour avec les informations de l'utilisateur sélectionné.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">
    
    <LinearLayout
        android:orientation="vertical"
        android:paddingLeft="6dip"
        android:paddingRight="6dip"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content">
        
        <TextView
            android:text="@string/forName"
            android:textAppearance="?android:textAppearanceLarge"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"/>
    
        <EditText 
            android:id="@+id/forName"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"/>
        
        <TextView
            android:text="@string/name"
            android:textAppearance="?android:textAppearanceLarge"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"/>
    
        <EditText 
            android:id="@+id/name"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"/>
    
        <LinearLayout
            android:orientation="horizontal"
            android:layout_gravity="right"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content">
            
            <Button 
                android:id="@+id/insertOrUpdate"
                android:onClick="insertOrUpdate"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
            
            <Button 
                android:id="@+id/delete"
                android:text="@string/delete"
                android:onClick="delete"
                android:visibility="gone"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
            
            <Button 
                android:id="@+id/initialize"
                android:text="@string/initialize"
                android:onClick="initialize"
                android:visibility="gone"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
                
        </LinearLayout>
    
    </LinearLayout>
        
    <ListView 
        android:id="@+id/users"
        android:layout_height="0dip"
        android:layout_weight="1"
        android:layout_width="fill_parent" />
        
</LinearLayout>
Dans le Layout ci dessus, on remarquera en particulier que les boutons ne sont pas tous présents par défaut. Les boutons "Enregistrer", "Supprimer" et "Annuler" ne sont proposés que si un utilisateur de la liste à été sélectionné. L'attribut android:onClick des balises Button permet de spécifier le nom de la méthode appelée lorsque lorsque le bouton est pressé. Ces méthodes doivent être déclarées dans la ou les Activity qui utilisent le Layout comme content view. Elle doivent être publiques et prendre un unique paramètre de type View.

Pour le rendu de la liste des utilisateurs, nous utilisons un ListAdapter afin de faire le pont entre la ListView (couche de présentation) et notre liste d'utilisateurs (modèle de données).
package net.androgames.blog.sample.rest.client;

import java.util.List;

import android.content.Context;
import android.database.DataSetObserver;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ListView;
import android.widget.TextView;

public class UserAdapter extends BaseAdapter {

    private List<User> users;
    private LayoutInflater inflater;

    public UserAdapter(final ListView list, Context context) {
        this.inflater = LayoutInflater.from(context);
        // on attache l'adapter à la ListView
        list.setAdapter(this);
        // raffraichissement de la liste lorsque
        // une donnée est modifiée
        registerDataSetObserver(new DataSetObserver() {
            public void onChanged() {
                list.invalidateViews();
            }
        });
    }

    public int getCount() {
        if (users == null) {
            return 0;
        } else {
            return users.size();
        }
    }

    public Object getItem(int position) {
        return users.get(position);
    }

    public long getItemId(int position) {
        return position;
    }

    public View getView(int position, View convertView, ViewGroup parent) {
        
        TaskHolder holder;
        
        // récupération du holder
        if (convertView == null) {
            convertView = inflater.inflate(R.layout.user, null);
            holder = new TaskHolder();
            holder.name = (TextView) convertView.findViewById(R.id.name);
            holder.id = (TextView) convertView.findViewById(R.id.id);
            convertView.setTag(holder);
        } else {
            holder = (TaskHolder) convertView.getTag();
        }
        
        // affichage de l'utilisateur d'index demande
        // via l'utilisation du holder
        if (position < getCount()) {
            User user = (User) getItem(position);
            holder.name.setText(user.getPrenom() + " " +user.getNom());
            holder.id.setText(user.getId());
        }
        
        return convertView;
    }
    
    public void setUsers(List<User> users) {
        this.users = users;
        // mise à jour de la liste
        notifyDataSetChanged();
    }
    
    public void addUser(User user) {
        if (!users.contains(user)) {
            users.add(user);
            // mise à jour de la liste
            notifyDataSetChanged();
        }
    }

    public void removeUser(User user) {
        users.remove(user);
        // mise à jour de la liste
        notifyDataSetChanged();
    }
    
    /**
     * Holder class :
     * Permet de ne pas multiplier le nombre
     * d'instance de View utilisées pour
     * l'affichae des utilisateurs dans la
     * ListView
     */
    static class TaskHolder {
        TextView name;
        TextView id;
    }

}
La méthode getView de l'Adapter permet de retourner une instance du LinearLayout utilisé pour le rendu d'un utilisateur (/res/layout/user.xml) et dans lequel les vues sont remplies avec les données de l'utilisateur en position demandée.

Appels aux services REST

L'interface utilisateur maintenant en place, nous allons nous pencher sur les appels aux services REST. Afin de ne pas tomber dans les fameuses erreurs ANR (Application Not Responding), les appels aux services s'exécuteront dans un Thread différent de celui utilisé pour les interactions avec l'utilisateur (rendu graphique, capture des événements, ...). Pour cela, nous utiliserons des AsyncTask afin que l'envoi de la requête au serveur, l'attente de la réponse et la récupération de la réponse s'exécute en parallèle du Thread principal. La méthode est similaire à l'utilisation du de la classe SwingWorker de Java 6.
/**
 * Classe abstraite pour l'envoi de requètes asynchrones au serveur.
 */
public abstract class AbstractTask 
        extends AsyncTask<HttpRequestBase, Void, HttpResponse> {
    
    /**
     * Appelée avant le lancement du traitement en arrière plan.
     * Cette méthode est exécutée dans le Thread appelant.
     */
    protected void onPreExecute() {}
    
    /**
     * Traitement en arrière plan.
     * Cette méthode est exécuté dans un Thread différent
     * du Thread appelant.
     */
    protected HttpResponse doInBackground(final HttpRequestBase...requests) {}

    /**
     * Appelée après la fin du traitement en arrière plan.
     */
    protected void onPostExecute(final HttpResponse response) {}
    
    /**
     * Traitement spécifique du JSON
     * @param in Le contenu de la réponse HTTP OK
     */
    protected abstract void handleJson(final InputStream in);

};
Cette classe nous permet de définir un comportement générique pour chacun des appels aux services REST ainsi qu'un comportement spécifique à implémenter :
onPreExecute
Cette méthode est appelée dans le Thread principal avant que le traitement en tâche de fond ne soit lancé. Dans notre cas, nous l'utiliserons pour afficher une fenêtre popup d'attente.
doInBackground
Cette méthode appelée dans un Thread annexe permet de lancer un traitement en tâche de fond. Nous l'utiliserons pour effectuer les requêtes HTTP à destination du serveur et attendre la réponse de ce dernier. Cette méthode retourne un objet dans le type correspond à celui de la méthode ci dessous.
onPostExecute
Cette méthode est appelée dans le Thread principal une fois que le traitement en tâche de fond a terminé.Elle prend en paramètre le résultat du traitement effectué en tache de fond. Dans notre cas, cette méthode sera utilisée pour analyser la réponse du serveur et traiter le contenu de celle-ci.
handleJson
Cette méthode abstraite doit être surchargée pour traiter de manière spécifique le contenu de la réponse du serveur au format JSON.
Nous utiliserons 4 implémentations différentes de cette classe abstraite, chacune ayant pour but le traitement d'un type particulier de réponse du serveur :
/**
 * Récupération de la liste des User
 */
private class ListUsersTask extends AbstractTask {

    protected void handleJson(final InputStream in) {
        final Type collectionType = new TypeToken<List<User>>(){}.getType();
        List<User> users = null;
        synchronized (lock) {
            users = gson.fromJson(new InputStreamReader(in), collectionType);
        }
        // La liste récupérée initialement est la référence
        // des User pour l'application Android
        adapter.setUsers(users);
    }

};
/**
 * Recuperation d'un utilisateur
 * déjà référencé localement
 */
private class GetUserTask extends AbstractTask {

    protected void handleJson(final InputStream in) {
        synchronized (lock) {
            updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class));
        }
    }

};
/**
 * Récupération d'un nouvel utilisateur
 * non encore référencé localement
 */
private class AddUserTask extends AbstractTask {

    protected void handleJson(final InputStream in) {
        synchronized (lock) {
            updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class));
        }
        adapter.addUser(currentUser);
    }

};
/**
 * Suppression d'un utilisateur
 * référencé localement
 */
private class DeleteUserTask extends AbstractTask {

    protected void handleJson(final InputStream in) {
        User fakeUser = new User();
        try {
            fakeUser.setId(new BufferedReader(
                    new InputStreamReader(in, ENCODING_UTF_8)).readLine());
        } catch (Exception e) {}
        adapter.removeUser(fakeUser);
        updateCurrentUser(null);
    }

};
Dans le cas de la suppression d'un utilisateur, le serveur nous renvoie un objet de type String sans délimiteur d'objet JSON : '{' ou '['. Nous traitons donc le contenu directement comme une chaine de caractères au moyen d'un BufferedReader.

Pour lancer ces traitements asynchrones d'envoi des requêtes au serveur, nous devons au préalable construire la HttpRequestBase que nous allons passer en paramètre à l'une de nos AbstractTask. Le code ci-dessous nous permet de mettre à jour l'utilisateur en cours d'édition en quelques étapes :
  1. Création d'un bean User avec les informations modifiées du formulaire
  2. Création d'une instance de la classe HttpPost pointant sur /user/{user.getId()}
  3. Le Content-Type du Header de la requête précise que le contenu est de type application/json
  4. Le bean User est sérialisé en JSON via la librairie Gson
  5. La représentation JSON est encodée en UTF-8 et positionnée dans la requête HTTP
  6. La requête est envoyé via une GetUserTask afin de récupérer le bean User retourné par le serveur
// mise a jour du currentUser
User updatedUser = new User();
updatedUser.setNom(name.getEditableText().toString());
updatedUser.setPrenom(forName.getEditableText().toString());
// création d'une requête de type POST
// l'URL contient l'ID du User à mettre à jour
HttpPost request = new HttpPost(
        getString(R.string.user_endpoint) + "/" + currentUser.getId());
// précision du Content-Type
request.setHeader("Content-Type", JSON_CONTENT_TYPE);
synchronized (lock) {
    try {
        // l'objet de type User sérialisé est envoyé dans le corps
        // de la requête HTTP et encodé en UTF-8 (cf. Jackson)
        request.setEntity(new StringEntity(gson.toJson(updatedUser), ENCODING_UTF_8));
    } catch (UnsupportedEncodingException e) {}
}
(new GetUserTask()).execute(request);
L'emploi de l'encodage UTF-8 est imposé par la librairie Jackson utilisé par Jersey côté serveur pour la sérialisation / désérialisation du JSON. En espérant un support de l'encodage ISO-8859-1 dans une prochaine release...

Pour l'appel aux autres méthodes du serveur vous pouvez regarder le code complet de l'Activity donné ci-dessous :
package net.androgames.blog.sample.rest.client;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Type;
import java.util.List;

import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.DialogInterface.OnClickListener;
import android.os.AsyncTask;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.AdapterView.OnItemClickListener;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;

public class UserConsumer extends Activity implements OnItemClickListener {
    
    private static final DefaultHttpClient httpClient = new DefaultHttpClient();
    static {
        HttpParams params = new BasicHttpParams();
        HttpProtocolParams.setContentCharset(params, "UTF-8");
        httpClient.setParams(params); 
    }
    
    private static final Gson gson = new Gson();

    private static final String JSON_CONTENT_TYPE = "application/json; charset=UTF-8";
    private static final String ENCODING_UTF_8 = "UTF-8";

    private static final int DIALOG_ERROR = 0;
    private static final int DIALOG_LOADING = 1;
    
    private EditText name, forName;
    private Button insertOrUpdate, initialize, delete;
    
    private User currentUser; // utilisateur courant
    private UserAdapter adapter; // utilisé comme référentiel local
    
    private Object lock = new Object();
    
    /**
     * Classe abstraite pour l'envoi de requêtes asynchrones au serveur.
     */
    private abstract class AbstractTask 
            extends AsyncTask<HttpRequestBase, Void, HttpResponse> {
        
        /**
         * Appelée avant le lancement du traitement en arrière plan.
         * Cette méthode est exécutée dans le Thread appelant.
         */
        protected void onPreExecute() {
            showDialog(DIALOG_LOADING);
        }
        
        /**
         * Traitement en arrière plan.
         * Cette méthode est exécutée dans un Thread différent
         * du Thread appelant.
         */
        protected HttpResponse doInBackground(final HttpRequestBase...requests) {
            HttpResponse response = null;
            synchronized (lock) {
                try {
                    response = httpClient.execute(requests[0]);
                } catch (Exception e) {
                    Log.e(UserConsumer.class.getSimpleName(), 
                            "Erreur d'appel au serveur", e);
                }
            }
            return response;
        }

        /**
         * Appelé après la fin du traitement en arrière plan.
         */
        protected void onPostExecute(final HttpResponse response) {
            dismissDialog(DIALOG_LOADING);
            if (response == null 
                    || !(response.getStatusLine()
                            .getStatusCode() == HttpStatus.SC_OK)) {
                showDialog(DIALOG_ERROR);
            } else {
                try {
                    handleJson(response.getEntity().getContent());
                } catch (IOException e) {
                    Log.e(UserConsumer.class.getSimpleName(), 
                            "Erreur de flux", e);
                }
            }
        }
        
        /**
         * Traitement spécifique du JSON
         * @param in Le contenu de la réponse HTTP OK
         */
        protected abstract void handleJson(final InputStream in);

    };
    
    /**
     * Récupération de la liste des User
     */
    private class ListUsersTask extends AbstractTask {

        protected void handleJson(final InputStream in) {
            final Type collectionType = new TypeToken<List<User>>(){}.getType();
            List<User> users = null;
            synchronized (lock) {
                users = gson.fromJson(new InputStreamReader(in), collectionType);
            }
            // La liste récupérée initialement est la référence
            // des User pour l'application Android
            adapter.setUsers(users);
        }

    };
    
    /**
     * Recuperation d'un utilisateur
     * déjà référencé localement
     */
    private class GetUserTask extends AbstractTask {

        protected void handleJson(final InputStream in) {
            synchronized (lock) {
                updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class));
            }
        }

    };
    
    /**
     * Récupération d'un nouvel utilisateur
     * non encore référencé localement
     */
    private class AddUserTask extends AbstractTask {

        protected void handleJson(final InputStream in) {
            synchronized (lock) {
                updateCurrentUser(gson.fromJson(new InputStreamReader(in), User.class));
            }
            adapter.addUser(currentUser);
        }

    };
    
    /**
     * Suppression d'un utilisateur
     * référencé localement
     */
    private class DeleteUserTask extends AbstractTask {

        protected void handleJson(final InputStream in) {
            User fakeUser = new User();
            try {
                fakeUser.setId(new BufferedReader(
                        new InputStreamReader(in, ENCODING_UTF_8)).readLine());
            } catch (Exception e) {}
            adapter.removeUser(fakeUser);
            updateCurrentUser(null);
        }

    };
    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        adapter = new UserAdapter((ListView) findViewById(R.id.users), this);
        insertOrUpdate = (Button) findViewById(R.id.insertOrUpdate);
        initialize = (Button) findViewById(R.id.initialize);
        delete = (Button) findViewById(R.id.delete);
        name = (EditText) findViewById(R.id.name);
        forName = (EditText) findViewById(R.id.forName);
    }
    
    @Override
    public void onResume() {
        super.onResume();
        // recuperation de tous les utilisateurs
        (new ListUsersTask()).execute(new HttpGet(getString(R.string.user_endpoint)));
        // initialisation des actions
        ((ListView) findViewById(R.id.users)).setOnItemClickListener(this);
        // initialisation de l'IHM
        // currentUser peut ne pas être null
        // si l'activité a été résumée
        updateCurrentUser(currentUser);
    }
    
    /**
     * Clique sur le bouton Créer ou Enregistrer
     * @param v
     */
    public void insertOrUpdate(View v) {
        if (currentUser == null) {
            // nouvel utilisateur
            User user = new User();
            user.setNom(name.getEditableText().toString());
            user.setPrenom(forName.getEditableText().toString());
            // création d'une requête de type POST
            // l'URL contient l'ID du User Ã  mettre Ã  jour
            HttpPut request = new HttpPut(getString(R.string.user_endpoint));
            // précision du Content-Type
            request.setHeader("Content-Type", JSON_CONTENT_TYPE);
            synchronized (lock) {
                try {
                    // l'objet de type User sérialisé est envoyé dans le corps
                    // de la requête HTTP et encodé en UTF-8 (cf. Jackson)
                    request.setEntity(new StringEntity(
                            gson.toJson(user), ENCODING_UTF_8));
                } catch (UnsupportedEncodingException e) {}
            }
            (new AddUserTask()).execute(request);
        } else {
            // mise a jour du currentUser
            User updatedUser = new User();
            updatedUser.setNom(name.getEditableText().toString());
            updatedUser.setPrenom(forName.getEditableText().toString());
            // création d'une requête de type POST
            // l'URL contient l'ID du User Ã  mettre Ã  jour
            HttpPost request = new HttpPost(
                    getString(R.string.user_endpoint) + "/" + currentUser.getId());
            // précision du Content-Type
            request.setHeader("Content-Type", JSON_CONTENT_TYPE);
            synchronized (lock) {
                try {
                    // l'objet de type User sérialisé est envoyé dans le corps
                    // de la requête HTTP et encodé en UTF-8 (cf. Jackson)
                    request.setEntity(new StringEntity(
                            gson.toJson(updatedUser), ENCODING_UTF_8));
                } catch (UnsupportedEncodingException e) {}
            }
            (new GetUserTask()).execute(request);
        }
    }
    
    /**
     * Clique sur le bouton Supprimer
     * @param v
     */
    public void delete(View v) {
        // envoi d'une requête DELETE au serveur
        // sur l'URL correspondant au User Ã  supprimer
        (new DeleteUserTask()).execute(new HttpDelete(
                getString(R.string.user_endpoint) + "/" + currentUser.getId()));
    }
    
    /**
     * Clique sur le bouton Annuler
     * @param v
     */
    public void initialize(View v) {
        // raz du formulaire
        updateCurrentUser(null);
    }

    // Met a jour le formulaire avec les
    // informations de l'utilisateur passé
    // en paramètre. Si le formulaire est 
    // positionné sur les données d'un utilisateur
    // de même id, le référentiel local est mis à jour
    private void updateCurrentUser(User user) {
        if (user == null) {
            currentUser = null;
            name.setText("");
            forName.setText("");
            delete.setVisibility(View.GONE);
            initialize.setVisibility(View.GONE);
            insertOrUpdate.setText(R.string.create);
        } else {
            if (!user.equals(currentUser)) {
                // changement de User
                currentUser = user;
            } else {
                // mise a jour des informations du User
                // dans le référentiel local
                currentUser.setNom(user.getNom());
                currentUser.setPrenom(user.getPrenom());
                adapter.notifyDataSetChanged();
            }
            // mise à jour du formulaire
            name.setText(currentUser.getNom());
            forName.setText(currentUser.getPrenom());
            delete.setVisibility(View.VISIBLE);
            initialize.setVisibility(View.VISIBLE);
            insertOrUpdate.setText(R.string.update);
        }
    }
    
    @Override
    public Dialog onCreateDialog(int dialogId) {
        Dialog dialog = null;
        AlertDialog.Builder builder = null;
        switch (dialogId) {
        
            case DIALOG_LOADING : // recuperation en cours...
                dialog = new ProgressDialog(this);
                ((ProgressDialog) dialog).setIndeterminate(true);
                ((ProgressDialog) dialog).setMessage(getString(R.string.loading));
                break;
                
            case DIALOG_ERROR : // message d'erreur
                builder = new AlertDialog.Builder(this);
                builder.setTitle(R.string.error)
                       .setMessage(R.string.error_message)
                       .setNegativeButton(R.string.close, new OnClickListener() {
                            public void onClick(DialogInterface dialog, int which) {
                                dialog.dismiss();
                            }
                        });
                dialog = builder.create();
                break;
        }
        return dialog;
    }
    
    /**
     * L'utilisateur à cliqué sur un User de la liste,
     * le formulaire est mis à jour avec les informations
     * du User récupérés depuis le serveur
     */

    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        // mise a jour du formulaire avec les informations
        // du User sélectionné par l'utilisateur
        updateCurrentUser((User) adapter.getItem(position));
        
        // on aurait également pu demander l'utilisateur 
        // au serveur à chaque fois, mais on considere
        // que la liste initialement chargée est notre
        // référence afin de garder un jeu de donnée cohérent
        
        // (new GetUserTask()).execute(new HttpGet(
        //        getString(R.string.user_endpoint) + 
        //        "/" + ((User) adapter.getItem(position)).getId()));
    }
    
}
Vous pouvez récupérer l'exemple complet sur Google Code : SampleAndroidRestClient.
Fork me on GitHub