Le voyage du dev en droïde

Publier le 10 juin 2021 20:57

L'ilôt droide

Eh bien bonjour Mesdames, Mesdemoiselles et Messieurs et bienvenue dans notre nouveau projet de vacance ! Je suis votre nouveau Capitaine et je vais vous expliquer en détail ce qui va nous occuper pendant les prochaines semaines. Mais avant cela, j'ai une annonce à faire !

J'aimerais remercier particulièrement @carl_chenet pour la surprise qu'il m'a faite ! Pendant que je faisais ma veille technologique, j'ai remarqué que mon dernier article apparaissait deux fois. J'ai d'abord pensé que j'avais trop bu de rhum, alors dans le doute j'en ai repris une chope, mais rien n'y faisait : je voyais toujours double ! En sortant mes jumelles, j'ai remarqué que la deuxième occurence provenait du journal du hacker et que l'origine de la soumission venait de Carl. Alors rien que pour ça, je te dis personnellement merci !

Cet aparté terminé, revenons au projet qui nous préoccupe aujourd'hui. Sans surprise, je vais sortir ma casquette de dev Android pour vous faire voyager à destination de mon application mobile. L'objectif de cette excursion est de pouvoir fournir une version du blog sous Android (à ne pas confondre avec "sous stéroïde"). Je ne suis cependant pas sûr d'aller jusqu'à l'étape de déploiement sur le play store, nous verrons bien.

Alors sans plus attendre...

Embarquement imminent

Dans quoi tu nous embarques ? C'est quoi l'objectif ? Qu'est-ce que tu entends par "Version du blog sous Android" ?

Le Pare-Fou prend une feuille, un stylo, gribouille un schéma. Jette la feuille. Ouvre Paint et affiche le résultat suivant.

Schéma

Tada ! C'est fou ce qu'un schéma peut m'éviter un monologue sans fin ! Bon aller, je vais vous faire le tour de l'île !

Sur la partie de gauche, nous avons la partie du blog. Sur mon blog, mes scènes sont écrites en HTML qui sont ensuite transférés dans un fichier XML. Ce fichier XML permet entre autres d'alimenter mon flux RSS pour que mes spectateurs soient avertis de mes nouvelles pièces de théâtre.

Sur la partie de droite, c'est le rendu final de mon application. Enfin, plutôt un premier jet. L'objectif sera découpé en plusieurs parties :

  • prendre le fichier XML disponible sur mon blog.

  • analyser ce fichier pour en sortir les informations pertinentes (les moussaillons développeurs parlent plutôt de parser un fichier).

  • disposer les informations sur une vue Android.

Maintenant que nous avons une voie maritime, que diriez-vous de lever l'ancre !

Pêchons le fichier

Je vous considère comme des petits mousses ! Au chocolat, à la vanille, à la fraise, peu importe. Pour pêcher un poisson, il faut deux choses : un appât et une canne à pêche. Le poisson peut être à différents endroits : la mer, l'océan, une rivière, un fleuve, un étang. Si je vous dis que tout ça est exactement la même chose pour récupérer un fichier, vous en pensez quoi ?

Je pense que tu devrais arrêter le rhum !

Oui alors ça c'est un autre débat qui concerne mon médecin et moi-même, je vous prierai de ne pas vous en mêler !

Pour reprendre ma comparaison avec le poisson. Un fichier peut être placé à différents endroits : un disque dur, un serveur, une clé USB. Ce même fichier nécessite une manière de l'attraper : un chemin d'accès et un protocole. L'endroit de mon fichier est sur mon site web, son chemin d'accès est son URL et le protocole qui sera utilisé est le HTTP. Maintenant que vous savez ça, comment le traduire en Android ?

Rebelote, on découpe tout ça en étape :

  1. obtenir une connexion à notre URL. (openConnection)

  2. préparer notre requête (GET, POST, les temps d'attente avant de sortir en erreur...)

  3. lancer notre requête (connect())

  4. récupérer la réponse (getInputStream())

  5. fermer les écoutilles (fermer tout ce qu'on a précédemment ouvert).

Voici ce que ça donnerait en Java, dans le fichier MainActivity.

public class MainActivity extends AppCompatActivity {
    private static final String URL = "https://leparefou.fr/feed/rss";
    private static final String TAG = "ACTIVITY_MAIN";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        // Préparation de certains flux de lecture / écriture (IO)
        StringBuilder data = new StringBuilder();
        HttpURLConnection conn = null;
        InputStreamReader isr = null;
        BufferedReader br = null;
        try {
            // 1) Obtenir une connexion à notre URL
            java.net.URL url = new URL(URL);
            conn = (HttpURLConnection) url.openConnection();

            // 2) Préparation de notre requête
            conn.setReadTimeout(10000);
            conn.setConnectTimeout(15000);
            conn.setRequestMethod("GET");
            conn.setDoInput(true);

            // 3) Lancer notre requête
            conn.connect();

            // 4) Récupération de la réponse
            try (InputStream stream = conn.getInputStream()) { // try with resources
                isr = new InputStreamReader(stream);
                br = new BufferedReader(isr);
                String strCurrentLine;
                while ((strCurrentLine = br.readLine()) != null) {
                    Log.d(TAG, strCurrentLine);
                    data.append(strCurrentLine);
                }
            }
        } catch (MalformedURLException | ProtocolException e) {
            Log.e(TAG, "Une erreur lors de la requête est levée.");
            e.printStackTrace();
        } catch (IOException e) {
            Log.e(TAG, "Une erreur de flux est levée.");
            e.printStackTrace();
        } finally { // 5) Fermer les écoutilles !
            try {
                if (br != null)
                    br.close();
                if (isr != null)
                    isr.close();
            } catch (IOException e) {
                Log.e(TAG, "Une erreur de flux est levée.");
                e.printStackTrace();
            }
            if (conn != null)
                conn.disconnect();
        }
    }
}

Comme vous me faites confiance aveuglement, vous lancer le programme qui compile, qui démarre et... PAF ! Premier boulet de canon intitulé NetworkOnMainThreadException. Je vous rassure il est simple à esquiver !

Le boulet que vous venez de recevoir est envoyé lorsque la tâche que vous effectuez bloque le fonctionnement de la Thread principale. Pour corriger ce problème, il faut donc déléguer le travail à une autre Thread qui se chargera de récupérer les informations de son côté.

Voici un exemple de correction que je vous propose avec les AsyncTask.

public class MainActivity extends AppCompatActivity {
    private static final String URL = "https://leparefou.fr/feed/rss";
    private static final String TAG = "ACTIVITY_MAIN";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        loadAsyncTask();
    }

    private void loadAsyncTask() {
        new DownLoadTask().execute();
    }

    private static class DownLoadTask extends AsyncTask<Void, Void, String> {
        @Override
        protected String doInBackground(Void... voids) {
            // Préparation de certains flux de lecture / écriture (IO)
            StringBuilder data = new StringBuilder();
            HttpURLConnection conn = null;
            InputStreamReader isr = null;
            BufferedReader br = null;
            try {
                // 1) Obtenir une connexion à notre URL
                java.net.URL url = new URL(URL);
                conn = (HttpURLConnection) url.openConnection();

                // 2) Préparation de notre requête
                conn.setReadTimeout(10000);
                conn.setConnectTimeout(15000);
                conn.setRequestMethod("GET");
                conn.setDoInput(true);

                // 3) Lancer notre requête
                conn.connect();

                // 4) Récupération de la réponse
                try (InputStream stream = conn.getInputStream()) { // try with resources
                    isr = new InputStreamReader(stream);
                    br = new BufferedReader(isr);
                    String strCurrentLine;
                    while ((strCurrentLine = br.readLine()) != null) {
                        Log.d(TAG, strCurrentLine);
                        data.append(strCurrentLine);
                    }
                }
            } catch (MalformedURLException | ProtocolException e) {
                Log.e(TAG, "Une erreur lors de la requête est levée.");
                e.printStackTrace();
            } catch (IOException e) {
                Log.e(TAG, "Une erreur de flux est levée.");
                e.printStackTrace();
            } finally { // 5) Fermer les écoutilles !
                try {
                    if (br != null)
                        br.close();
                    if (isr != null)
                        isr.close();
                } catch (IOException e) {
                    Log.e(TAG, "Une erreur de flux est levée.");
                    e.printStackTrace();
                }
                if (conn != null)
                    conn.disconnect();
            }
            return data.toString();
        }
    }
}

Les AsyncTask ? Mais elles ne sont pas ...

Chut Je suis le Capitaine, je maitrise la situation ! Ne parle pas de cette tempête ! Pour la peine tu iras à la plonge.

Euh Capitaine... ça ne fonctionne toujours pas !

Bien vue moussaillon ! C'est le deuxième boulet qu'on recupère ! Que dit-il ?

SecurityException, Capitaine. Permission denied (missing INTERNET permission?)

Ce boulet est bien poli ! Il nous guide sur la marche à suivre ! Allons dans le Manifest de notre application Android et ajoutons cette permission !

<uses-permission android:name="android.permission.INTERNET"/>

Ça y est ! Nous avons les données de notre fichier ! On a notre poisson ! Un poisson c'est bien, mais le cuisiner c'est mieux ! Alors, continuons de travailler sur notre fichier.

Désarêter le fichier

Comme je vous l'ai présenté sur ma carte : le fichier est écrit en XML. Il faudra donc parcourir notre fichier et en fonction des informations récoltées, les transformer en donnée cohérente pour notre projet.

Le XML est assez simple à lire, voici ce que l'on a dans mon cas :

  • le tag rss englobe tout.

  • le tag channel recupère certaines informations de mon blog.

  • le tag item recupère les informations de mes scènes (title, link, description).

Notre objectif sera donc de créer une classe qui prendra en entrée le contenu du fichier XML pour nous sortir une liste d'objet Java.

Commençons par créer notre classe qui va parser le contenu du fichier.

public class XMLParser {
    private static final String ns = "";
    private static final String TAG = "XMLParser";

    /**
    * Transform input stream into list of items
    * @param in response fetch by URL
    * @return list of items, in this case Post blog
    */
    public static List<Item> parse(InputStream in) {
        Log.d(TAG, "Begin parse");
        XmlPullParser parser = Xml.newPullParser();
        try {
            parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false);
            parser.setInput(in, null);
            return readRSS(parser);
        } catch (XmlPullParserException | IOException e) {
            Log.e(TAG, "Une erreur lors du parsing est levée.");
            e.printStackTrace();
            return null;
        } finally {
            Log.d(TAG, "End parse");
        }
    }

    private static List<Item> readRSS(XmlPullParser parser) throws IOException, XmlPullParserException {
        Log.d(TAG, "Begin readRSS");
        List<Item> entries = new ArrayList<>();
        parser.nextTag();
        parser.require(XmlPullParser.START_TAG, ns, "rss");
        while (parser.next() != XmlPullParser.END_TAG) {
            if (parser.getEventType() != XmlPullParser.START_TAG) {
                continue;
            }
            String name = parser.getName();
            if (name.equals("channel")) {
                entries.addAll(readChannel(parser));
            } else {
                skip(parser);
            }
        }
        Log.d(TAG, "End readRSS");
        return entries;
    }

    private static List<Item> readChannel(XmlPullParser parser) throws IOException, XmlPullParserException {
        Log.d(TAG, "Begin readChannel");
        parser.require(XmlPullParser.START_TAG, ns, "channel");
        List<Item> entries = new ArrayList<>();
        while (parser.next() != XmlPullParser.END_TAG) {
            if (parser.getEventType() != XmlPullParser.START_TAG) {
                continue;
            }
            String name = parser.getName();
            Log.d(TAG, "Name : " + name);
            if (name.equals("item")) {
                entries.add(readItem(parser));
            } else {
                skip(parser);
            }
        }
        Log.d(TAG, "End readChannel");
        return entries;
    }

    private static Item readItem(XmlPullParser parser) throws IOException, XmlPullParserException {
        Log.d(TAG, "Start readItem");
        parser.require(XmlPullParser.START_TAG, ns, "item");
        String title = null;
        String description = null;
        String link = null;
        while (parser.next() != XmlPullParser.END_TAG) {
            if (parser.getEventType() != XmlPullParser.START_TAG) {
                continue;
            }
            String name = parser.getName();
            switch (name) {
                case "title":
                    title = readSomething(parser, "title");
                    break;
                case "description":
                    description = readSomething(parser, "description");
                    break;
                case "link":
                    link = readSomething(parser, "link");
                    break;
                default:
                    skip(parser);
                    break;
            }
        }
        Log.d(TAG, "End readItem");
        return new Item(title, link, description);
    }

    // Avoid repetitions like readTitle, readLink, readDescription with the same syntax
    private static String readSomething(XmlPullParser parser, String name) throws IOException, XmlPullParserException {
        Log.d(TAG, "Start read" + name);
        parser.require(XmlPullParser.START_TAG, ns, name);
        String something = readText(parser);
        parser.require(XmlPullParser.END_TAG, ns, name);
        Log.d(TAG, "End read" + name);
        return something;
    }

    private static String readText(XmlPullParser parser) throws IOException, XmlPullParserException {
        String result = "";
        if (parser.next() == XmlPullParser.TEXT) {
            result = parser.getText();
            parser.nextTag();
        }
        return result;
    }

    private static void skip(XmlPullParser parser) throws XmlPullParserException, IOException {
        if (parser.getEventType() != XmlPullParser.START_TAG) {
            throw new IllegalStateException();
        }
        int depth = 1;
        while (depth != 0) {
            switch (parser.next()) {
                case XmlPullParser.END_TAG:
                    depth--;
                    break;
                case XmlPullParser.START_TAG:
                    depth++;
                    break;
            }
        }
    }


    public static class Item {
        public final String title;
        public final String link;
        public final String description;

        // Only OuterClass XMLParser can instantiate this class
        private Item(String title, String link, String description) {
            this.title = title;
            this.link = link;
            this.description = description;
        }

        public String getTitle() {
            return title;
        }

        public String getLink() {
            return link;
        }

        public String getDescription() {
            return description;
        }

        // Optional - get a String representation of the instantiated Object
        @Override
        public String toString() {
            return "Item{" +
                    "title='" + title + '\'' +
                    ", link='" + link + '\'' +
                    ", description='" + description + '\'' +
                    '}';
        }
    }
}

Ici, j'ai choisi le fait d'avoir une seule méthode static public : parse. Tout le reste est protégé par le mot clé private.

C'est comme en cuisine, vous demandez un plat au restaurant, vous savez qu'il y a quelqu'un qui "cuisine" et à la fin vous avez votre plat. Vous ne voulez pas savoir comment le cuisinier a procédé pour obtenir le résultat qui se trouve sur votre table et vous lui faites confiance.

Même principe avec la machine à café : vous mettez votre capsule, de l'eau, vous appuyez sur un bouton et un café en ressort ! Vous faites confiance à votre machine et les méthodes "bouillir l'eau" et "filtrer l'eau avec le café" sont privées à la machine.

Vous noterez également que j'ai créé une classe interne static pour avoir une représentation logique de mes scènes (class Item).

Maintenant que nous avons décortiquer notre fichier, il n'y a plus qu'à ...

Dresser la table

Pour servir notre pièce de théatre, il nous faut un espace pour décrire nos scènes et un espace pour les placer sur la pièce.

Pour les scènes (class Item pour ceux qui ont suivis) il nous faudra :

  • un texte qui contient le titre de la scène en lettre capitale et centré horizontalement.

  • un texte qui contient le lien de la scène centré horizontalement.

  • un texte qui contient le contenu de la scène .

<?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/linear_post"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_horizontal"
            android:text="@string/title"
            android:textAlignment="center" />
    
        <TextView
            android:id="@+id/link"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_horizontal"
            android:text="@string/link"
            android:textAlignment="center" />
    
        <TextView
            android:id="@+id/description"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/description" />
    </LinearLayout>

Je vous laisse libre à votre imagination pour peaufiner cela, je suis Capitaine pas un maître coq !

Passons au positionnement des scènes sur la pièce. Loin de moi l'idée de vous faciliter la vie en vous évitant de passer par un RecyclerView, je vais choisir de placer mes scènes dans une liste les unes en dessous des autres.

Mais... on peut le faire avec une RecyclerView ! Où est le problème ?

Le problème vois-tu, c'est que j'aurai perdu le 3/4 de mon équipage ! Tu noteras que je n'ai pas fait de ViewModel, que je vous ai épargné les LiveData, que je ne me suis pas amusé à vous parler du pattern Observer qui en découle... et voilà que mon équipage prend les canots de sauvetage ! Revenez moussaillons ! J'éviterai de vous faire peur à l'avenir. Remarque... il ne suffit pas de grand-chose pour les effrayer ceux-là.

Reprenons notre ListView. Comme vous allez le voir, ce n'est pas grand chose au niveau de l'agencement.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <ListView
        android:id="@+id/list_item"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

Maintenant, c'est là que ça devient intéressant. Une ListView a besoin d'une classe Adapter pour fonctionner. Dans notre cas, ça sera une ArrayAdapter. L'objectif de cette ArrayAdapter est de prendre notre liste de scènes et de les positionner dans notre ListView. L'ArrayAdapter a donc besoin de connaitre le layout à utiliser (pour l'inflate) et la scène courante à ajouter. Voici ce que ça donne en jargon Android.

public class ItemsAdapter extends ArrayAdapter<XMLParser.Item> {
    public ItemsAdapter(Context context, List<XMLParser.Item> items) {
        super(context, 0, items);
    }

    @NonNull
    @Override
    public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
        // 1) La vue de notre post est définie dans post_layout
        if (convertView == null) {
            convertView = LayoutInflater.from(getContext()).inflate(R.layout.post_layout, parent, false);
        }
        
        // 2) On met à jour la vue
        TextView title = convertView.findViewById(R.id.title);
        TextView link = convertView.findViewById(R.id.link);
        TextView description = convertView.findViewById(R.id.description);

        XMLParser.Item item = getItem(position); // Récupère le post 1,2,3.....
        title.setText(item.getTitle());
        link.setText(item.getLink());
        description.setText(Html.fromHtml(item.getDescription())); // Le contenu est en HTML (CF le schema au début)
        return convertView;
    }
}

Désormais, il ne nous manque plus qu'à jouer au lego sur notre MainActivity en suivant ces étapes :

  • specifier notre layout qui contient notre ListView au début (setContentView)

  • créer un ItemsAdapter qui prendra en paramètre une liste de scène

  • récupérer notre ListView (findViewById) et lui affecter notre ItemsAdapter (setAdapter())

  • mettre à jours l'AsyncTask (onPostExecute) pour actualiser notre liste de scène et notifier l'ItemsAdapter (notifyDataSetChanged)

public class MainActivity extends AppCompatActivity {
    private static final String URL = "https://leparefou.fr/feed/rss";
    private static final String TAG = "ACTIVITY_MAIN";
    private static final List<XMLParser.Item> itemsList = new ArrayList<>();
    private static ItemsAdapter itemArrayAdapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        itemArrayAdapter = new ItemsAdapter(this, itemsList);
        ListView listView = findViewById(R.id.list_item);
        listView.setAdapter(itemArrayAdapter);

        loadAsyncTask();
    }

    private void loadAsyncTask() {
        new DownLoadTask().execute();
    }

    private static class DownLoadTask extends AsyncTask<Void, Void, List<XMLParser.Item>> {
        @Override
        protected List<XMLParser.Item> doInBackground(Void... voids) {
            // Préparation de certains flux de lecture / écriture (IO)
            HttpURLConnection conn = null;
            List<XMLParser.Item> items = null;
            try {
                // 1) Obtenir une connexion à notre URL
                java.net.URL url = new URL(URL);
                conn = (HttpURLConnection) url.openConnection();

                // 2) Préparation de notre requête
                conn.setReadTimeout(10000);
                conn.setConnectTimeout(15000);
                conn.setRequestMethod("GET");
                conn.setDoInput(true);

                // 3) Lancer notre requête
                conn.connect();

                // 4) Récupération de la réponse
                try (InputStream response = conn.getInputStream()) { // try with resources - auto-closable
                    items = XMLParser.parse(response);
                }
                return items;
            } catch (MalformedURLException | ProtocolException e) {
                Log.e(TAG, "Une erreur lors de la requête est levée.");
                e.printStackTrace();
                return items;
            } catch (IOException e) {
                Log.e(TAG, "Une erreur de flux est levée.");
                e.printStackTrace();
                return items;
            } finally { // 5) Fermer les écoutilles !
                if (conn != null)
                    conn.disconnect();
            }
        }

        @Override
        protected void onPostExecute(List<XMLParser.Item> items) {
            Log.d(TAG, items.toString());
            itemsList.addAll(items);
            itemArrayAdapter.notifyDataSetChanged();
        }
    }
}

Et voilà moussaillon, comment un vieux loup de mer se rassasie d'un met délectable sans arêtes !

Le Pare-Fou n'eut pas le temps de terminer son discours. Une tempête a eu le temps de se préparer et de renverser tout l'équipage. La tempête s'appelait AsyncTask.

À suivre...