Licence CC BY-NC-SA

TP : un explorateur de fichiers

Ce contenu est obsolète. Il peut contenir des informations intéressantes mais soyez prudent avec celles-ci.

Petit à petit, on se rapproche d'un contenu qui pourrait s'apparenter à celui des applications professionnelles. Bien entendu, il nous reste du chemin à parcourir, mais on commence à vraiment voir comment fonctionne Android !

Afin de symboliser notre entrée dans les entrailles du système, on va s'affairer ici à déambuler dans ses méandres. Notre objectif : créer un petit explorateur qui permettra de naviguer entre les fichiers contenus dans le terminal et faire en sorte de pouvoir exécuter certains de ces fichiers.

Objectifs

Contenu d'un répertoire

L'activité principale affiche le contenu du répertoire dans lequel on se situe. Afin de différencier rapidement les fichiers des répertoires, ces derniers seront représentés avec une couleur différente. La figure suivante vous donne un avant-goût de ce que l'on obtiendra.

Le dernier répertoire que contient le répertoire courant est « yume_android_sdk »

Notez aussi que le titre de l'activité change en fonction du répertoire dans lequel on se trouve. On voit sur la figure précédente que je me trouve dans le répertoire sdcard, lui-même situé dans mnt.

Navigation entre les répertoires

Si on clique sur un répertoire dans la liste, alors notre explorateur va entrer dedans et afficher la nouvelle liste des fichiers et répertoires. De plus, si l'utilisateur utilise le bouton Retour Arrière, alors il reviendra au répertoire parent du répertoire actuel. En revanche, si on se trouve à la racine de tous les répertoires, alors appuyer deux fois sur Retour Arrière fait sortir de l'application.

Préférences

Il faudra un menu qui permet d'ouvrir les préférences et où il sera possible de changer la couleur d'affichage des répertoires, comme à la figure suivante.

L'application contiendra un menu de préférences

Cliquer sur cette option ouvre une boîte de dialogue qui permet de sélectionner la couleur voulue, comme le montre la figure suivante.

Il sera possible de modifier la couleur d'affichage des répertoires

Action sur les fichiers

Cliquer sur un fichier fait en sorte de rechercher une application qui pourra le lire. Faire un clic long ouvre un menu contextuel qui permet soit de lancer le fichier comme avec un clic normal, soit de supprimer le fichier, ainsi que le montre la figure suivante.

Il est possible d'ouvrir ou de supprimer un fichier

Bien sûr, faire un clic long sur un répertoire ne propose pas d'exécuter ce dernier (on pourrait envisager de proposer de l'ouvrir, j'ai opté pour supprimer directement l'option).

Spécifications techniques

Activité principale

Un nouveau genre d'activité

La première chose à faire est de vérifier qu'il est possible de lire la carte SD avec les méthodes vues aux chapitres précédents. S'il est bien possible de lire la carte, alors on affiche la liste des fichiers du répertoire, ce qui se fera dans une ListView. Cependant, comme notre mise en page sera uniquement constituée d'une liste, nous allons procéder différemment par rapport à d'habitude. Au lieu d'avoir une activité qui affiche un layout qui contient une ListView, on va remplacer notre Activity par une ListActivity. Comme l'indique le nom, une ListActivity est une activité qui est principalement utilisée pour afficher une ListView. Comme il s'agit d'une classe qui dérive de Activity, il faut la traiter comme une activité normale, si ce n'est que vous n'avez pas besoin de préciser un layout avec void setContentView (View view), puisqu'on sait qu'il n'y a qu'une liste dans la mise en page. Elle sera alors ajoutée automatiquement.

Il est possible de récupérer la ListView qu'affiche la ListActivity à l'aide de la méthode ListView getListView (). Cette ListView est une ListView tout à fait banale que vous pouvez traiter comme celles vues dans le cours.

Adaptateur personnalisé

On associera les items à la liste à l'aide d'un adaptateur personnalisé. En effet, c'est la seule solution pour avoir deux couleurs dans les éléments de la liste. On n'oubliera pas d'optimiser cet adaptateur afin d'avoir une liste fluide. Ensuite, on voudra que les éléments soient triés de la manière suivante :

  • Les répertoires en premier, les fichiers en second.
  • Dans chacune de ces catégories, les éléments sont triés dans l'ordre alphabétique sans tenir compte de la casse.

Pour cela, on pourra utiliser la méthode void sort (Comparator<? super T> comparator) qui permet de trier des éléments en fonction de règles qu'on lui passe en paramètres. Ces règles implémentent l'interface Comparator de manière à pouvoir définir comment seront triés les objets. Votre implémentation de cette interface devra redéfinir la méthode int compare(T lhs, T rhs) dont l'objectif est de dire qui est le plus grand entre lhs et rsh. Si lhs est plus grand que rhs, on renvoie un entier supérieur à 0, si lhs est plus petit que rhs, on renvoie un entier inférieur à 0, et s'ils sont égaux, on renvoie 0. Vous devrez vérifier que cette méthode respecte la logique suivante :

  • compare(a,a) renvoie 0 pour tout a parce que a==a.
  • compare(a,b) renvoie l'opposé de compare(b,a) pour toutes les paires (a,b) (par exemple, si a > b, alors compare(a,b) renvoie un entier supérieur à 0 et compare(b,a) un entier inférieur à 0).
  • Si compare(a,b) > 0 et compare(b,c) > 0, alors compare(a,c) > 0 quelque que soit la combinaison (a, b, c).

Je comprends que ce soit un peu compliqué à comprendre, alors voici un exemple qui trie les entiers :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import java.util.Comparator;

public class EntierComparator implements Comparator<Integer> {
  @Override
  public int compare(Integer lhs, Integer rhs) {
  // Si lhs est supérieur à rsh, alors on retourne 1
  if(lhs > rhs)
    return 1;
  // Si lhs est inférieur à rsh, alors on retourne -1
  if(lhs < rhs)
    return -1;
  // Si lhs est égal à rsh, alors on retourne 0
  return 0;
  }
}

Ensuite, dans le code, on peut l'utiliser pour trier un tableau d'entiers :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Voici un tableau avec des entiers dans le mauvais ordre
Integer[] tableau = {0, -1, 5, 10, 9, 5, -10, 8, 21, 132};

// On convertit le tableau en liste
List<Integer> entiers = new ArrayList<Integer>(Arrays.asList(tableau));

// On écrit tous les entiers dans le Logcat, ils sont dans le désordre !
for(Integer i : entiers)
  Log.d("Avant le tri", Integer.toString(i));

// On utilise une méthode qui va trier les éléments de la liste
Collections.sort(entiers, new EntierComparator());

// Désormais, les entiers seront triés !
for(Integer i : entiers)
  Log.d("Après le tri", Integer.toString(i));

//La liste contient désormais {-10, -1, 0, 5, 5, 8, 9, 10, 21, 132}

Préférences

Nous n'avons qu'une préférence ici, qui chez moi a pour identifiant repertoireColorPref et qui contient la couleur dans laquelle nous souhaitons afficher les répertoires.

Comme il n'existe pas de vue qui permette de choisir une couleur, on va utiliser une vue développée par Google dans ses échantillons et qui n'est pas incluse dans le code d'Android. Tout ce qu'il faut faire, c'est créer un fichier Java qui s'appelle ColorPickerView et d'y insérer le code suivant :

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorMatrix;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.SweepGradient;
import android.view.MotionEvent;
import android.view.View;

public class ColorPickerView extends View {
  public interface OnColorChangedListener {
    void colorChanged(int color);
  }

  private Paint mPaint;
  private Paint mCenterPaint;
  private final int[] mColors;
  private OnColorChangedListener mListener;

  ColorPickerView(Context c, OnColorChangedListener l, int color) {
    super(c);
    mListener = l;
    mColors = new int[] {0xFFFF0000, 0xFFFF00FF, 0xFF0000FF, 0xFF00FFFF, 0xFF00FF00, 0xFFFFFF00, 0xFFFF0000};
    Shader s = new SweepGradient(0, 0, mColors, null);

    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mPaint.setShader(s);
    mPaint.setStyle(Paint.Style.STROKE);
    mPaint.setStrokeWidth(32);

    mCenterPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mCenterPaint.setColor(color);
    mCenterPaint.setStrokeWidth(5);
  }

  private boolean mTrackingCenter;
  private boolean mHighlightCenter;

  @Override 
  protected void onDraw(Canvas canvas) {
    int centerX = getRootView().getWidth()/2 - (int)(mPaint.getStrokeWidth()/2);
    float r = CENTER_X - mPaint.getStrokeWidth()*0.5f;

    canvas.translate(centerX, CENTER_Y);

    canvas.drawOval(new RectF(-r, -r, r, r), mPaint);      
    canvas.drawCircle(0, 0, CENTER_RADIUS, mCenterPaint);

    if (mTrackingCenter) {
      int c = mCenterPaint.getColor();
      mCenterPaint.setStyle(Paint.Style.STROKE);

      if (mHighlightCenter) {
        mCenterPaint.setAlpha(0xFF);
      } else {
        mCenterPaint.setAlpha(0x80);
      }
      canvas.drawCircle(0, 0, CENTER_RADIUS + mCenterPaint.getStrokeWidth(), mCenterPaint);

      mCenterPaint.setStyle(Paint.Style.FILL);
      mCenterPaint.setColor(c);
    }
  }

  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getRootView().getWidth(), CENTER_Y*2);
  }

  private static final int CENTER_X = 100;
  private static final int CENTER_Y = 100;
  private static final int CENTER_RADIUS = 32;

  private int floatToByte(float x) {
    int n = java.lang.Math.round(x);
    return n;
  }
  private int pinToByte(int n) {
    if (n < 0) {
      n = 0;
    } else if (n > 255) {
      n = 255;
    }
    return n;
  }

  private int ave(int s, int d, float p) {
    return s + java.lang.Math.round(p * (d - s));
  }

  private int interpColor(int colors[], float unit) {
    if (unit <= 0) {
      return colors[0];
    }
    if (unit >= 1) {
      return colors[colors.length - 1];
    }

    float p = unit * (colors.length - 1);
    int i = (int)p;
    p -= i;

    int c0 = colors[i];
    int c1 = colors[i+1];
    int a = ave(Color.alpha(c0), Color.alpha(c1), p);
    int r = ave(Color.red(c0), Color.red(c1), p);
    int g = ave(Color.green(c0), Color.green(c1), p);
    int b = ave(Color.blue(c0), Color.blue(c1), p);

    return Color.argb(a, r, g, b);
  }

  private int rotateColor(int color, float rad) {
    float deg = rad * 180 / 3.1415927f;
    int r = Color.red(color);
    int g = Color.green(color);
    int b = Color.blue(color);

    ColorMatrix cm = new ColorMatrix();
    ColorMatrix tmp = new ColorMatrix();

    cm.setRGB2YUV();
    tmp.setRotate(0, deg);
    cm.postConcat(tmp);
    tmp.setYUV2RGB();
    cm.postConcat(tmp);

    final float[] a = cm.getArray();

    int ir = floatToByte(a[0] * r +  a[1] * g +  a[2] * b);
    int ig = floatToByte(a[5] * r +  a[6] * g +  a[7] * b);
    int ib = floatToByte(a[10] * r + a[11] * g + a[12] * b);

    return Color.argb(Color.alpha(color), pinToByte(ir), pinToByte(ig), pinToByte(ib));
  }

  private static final float PI = 3.1415926f;

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    float x = event.getX() - CENTER_X;
    float y = event.getY() - CENTER_Y;
    boolean inCenter = java.lang.Math.sqrt(x*x + y*y) <= CENTER_RADIUS;

    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        mTrackingCenter = inCenter;
        if (inCenter) {
          mHighlightCenter = true;
          invalidate();
          break;
        }
      case MotionEvent.ACTION_MOVE:
        if (mTrackingCenter) {
          if (mHighlightCenter != inCenter) {
            mHighlightCenter = inCenter;
            invalidate();
          }
        } else {
          float angle = (float)java.lang.Math.atan2(y, x);

          float unit = angle/(2*PI);
          if (unit < 0) {
            unit += 1;
          }
          mCenterPaint.setColor(interpColor(mColors, unit));
          invalidate();
        }
        break;
      case MotionEvent.ACTION_UP:
        mListener.colorChanged(mCenterPaint.getColor());
        if (mTrackingCenter) {
          mTrackingCenter = false;
          invalidate();
        }
        break;
    }
    return true;
  }
}

Ce n'est pas grave si vous ne comprenez pas ce code compliqué, il permet juste d'afficher le joli rond de couleur et de sélectionner une couleur. En fait, la vue contient un listener qui s'appelle OnColorChangedListener. Ce listener se déclenche dès que l'utilisateur choisit une couleur. Afin de créer un objet de type ColorPickerView, on doit utiliser le constructeur ColorPickerView(Context c, OnColorChangedListener listener, int color) avec listener le listener qui sera déclenché dès qu'une couleur est choisie et color la couleur qui sera choisie par défaut au lancement de la vue.

Notre préférence, elle, sera une boîte de dialogue qui affichera ce ColorPickerView. Comme il s'agira d'une boîte de dialogue qui permettra de choisir une préférence, elle dérivera de DialogPreference.

Au moment de la construction de la boîte de dialogue, la méthode de callback void onPrepareDialogBuilder(Builder builder) est appelée, comme pour toutes les AlertDialog. On utilise builder pour construire la boîte, il est d'ailleurs facile d'y insérer une vue à l'aide de la méthode AlertDialog.Builder setView(View view).

Notre préférence a un attribut de type int qui permet de retenir la couleur que choisit l'utilisateur. Elle peut avoir un attribut de type OnColorChangedListener ou implémenter elle-même OnColorChangedListener, dans tous les cas cette implémentation implique de redéfinir la fonction void colorChanged(int color) avec color la couleur qui a été choisie. Dès que l'utilisateur choisit une couleur, on change notre attribut pour désigner cette nouvelle couleur.

On n'enregistrera la bonne couleur qu'à la fermeture de la boîte de dialogue, celle-ci étant marquée par l'appel à la méthode void onDialogClosed(boolean positiveResult) avec positiveResult qui vaut true si l'utilisateur a cliqué sur OK.

Réagir au changement de préférence

Dès que l'utilisateur change de couleur, il faudrait que ce changement se répercute immédiatement sur l'affichage des répertoires. Il nous faut donc détecter les changements de configuration. Pour cela, on va utiliser l'interface OnSharedPreferenceChangeListener. Cette interface fait appel à la méthode de callback void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) dès qu'un changement de préférence arrive, avec sharedPreferences l'ensemble des préférences et key la clé de la préférence qui vient d'être modifiée. On peut indiquer à SharedPreferences qu'on souhaite ajouter un listener à l'aide de la méthode void registerOnSharedPreferenceChangeListener (SharedPreferences.OnSharedPreferenceChangeListener listener).

Options

Ouvrir le menu d'options ne permet d'accéder qu'à une option. Cliquer sur celle-ci enclenche un intent explicite qui ouvrira la PreferenceActivity.

Navigation

Il est recommandé de conserver un File qui représente le répertoire courant. On peut savoir si un fichier est un répertoire avec la méthode boolean isDirectory() et, s'il s'agit d'un répertoire, on peut voir la liste des fichiers qu'il contient avec File[] listFiles().

Pour effectuer des retours en arrière, il faut détecter la pression du bouton adéquat. À chaque fois qu'on presse un bouton, la méthode de callback boolean onKeyDown(int keyCode, KeyEvent event) est lancée, avec keyCode un code qui représente le bouton pressé et event l'évènement qui s'est produit. Le code du bouton Retour arrière est KeyEvent.KEYCODE_BACK.

Il existe deux cas pour un retour en arrière :

  • Soit on ne se trouve pas à la racine de la hiérarchie de fichier, auquel cas on peut revenir en arrière dans cette hiérarchie. Il faut passer au répertoire parent du répertoire actuel et ce répertoire peut se récupérer avec la méthode File getParentFile().
  • Soit on se trouve à la racine et il n'est pas possible de faire un retour en arrière. En ce cas, on propose à l'utilisateur de quitter l'application avec la méthode de Context que vous connaissez déjà, void finish().

Visualiser un fichier

Nous allons bien entendu utiliser des intents implicites qui auront pour action ACTION_VIEW. Le problème est de savoir comment associer un type et une donnée à un intent, depuis un fichier. Pour la donnée, il existe une méthode statique de la classe Uri qui permet d'obtenir l'URI d'un fichier : Uri.fromFile(File file). Pour le type, c'est plus délicat. Il faudra détecter l'extension du fichier pour associer un type qui corresponde. Par exemple, pour un fichier .mp3, on indiquera le type MIME audio/mp3. Enfin, si on veut moins s'embêter, on peut aussi passer le type MIME audio/* pour chaque fichier audio.

Pour rajouter une donnée et un type en même temps à un intent, on utilise la méthode void setDataAndType(Uri data, String type), car, si on utilise la méthode void setData(Uri), alors le champ type de l'intent est supprimé, et si on utilise void setType(String), alors le champ data de l'intent est supprimé. Pour récupérer l'extension d'un fichier, il suffit de récupérer son nom avec String getName(), puis de récupérer une partie de ce nom : toute la partie qui se trouve après le point qui représente l'extension :

1
fichier.getName().substring(fichier.getName().indexOf(".") + 1)

int indexOf(String str) va trouver l'endroit où se trouve la première instance de str dans la chaîne de caractères, alors que String substring(int beginIndex) va extraire la sous-chaîne de caractères qui se situe à partir de beginIndex jusqu'à la fin de cette chaîne. Donc, si le fichier s'appelle chanson.mp3, la position du point est 7 (puisqu'on commence à 0), on prend donc la sous-chaîne à partir du caractère 8 jusqu'à la fin, ce qui donne « mp3 ». C'est la même chose que si on avait fait :

1
"musique.mp3".subSequence(8, "musique.mp3".length())

N'oubliez pas de gérer le cas où vous n'avez pas d'activité qui puisse intercepter votre intent.

Ma solution

Interface graphique

Facile, il n'y en a pas ! Comme notre activité est constituée uniquement d'une ListView, pas besoin de lui attribuer une interface graphique avec setContentView.

Choisir une couleur avec ColorPickerPreferenceDialog

Tout le raisonnement a déjà été expliqué dans les spécifications techniques :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ColorPickerPreferenceDialog extends DialogPreference implements OnColorChangedListener{
  private int mColor = 0;

  public ColorPickerPreferenceDialog(Context context, AttributeSet attrs) {
    super(context, attrs);
  }

  /**
   * Déclenché dès qu'on ferme la boîte de dialogue
  */
  protected void onDialogClosed(boolean positiveResult) {
    // Si l'utilisateur a cliqué sur « OK »
    if (positiveResult) {
      persistInt(mColor);
    // Ou getSharedPreferences().edit().putInt(getKey(), mColor).commit();
    }
    super.onDialogClosed(positiveResult);
  }

  /**
  * Pour construire la boîte de dialogue
  */
  protected void onPrepareDialogBuilder(Builder builder) {
    // On récupère l'ancienne couleur ou la couleur par défaut
    int oldColor = getSharedPreferences().getInt(getKey(), Color.BLACK);
    // On insère la vue dans la boîte de dialogue
    builder.setView(new ColorPickerView(getContext(), this, oldColor));

    super.onPrepareDialogBuilder(builder);
  }

  /**
  * Déclenché à chaque fois que l'utilisateur choisit une couleur
  */
  public void colorChanged(int color) {
    mColor = color;
  }
}

Il faut ensuite ajouter cette boîte de dialogue dans le fichier XML des préférences :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" >
  <PreferenceCategory android:title="@string/couleurs_pref" >
    <sdz.chapitreTrois.explorateur.ColorPickerPreferenceDialog
      android:key="repertoireColorPref"
      android:title="Répertoires"
      android:summary="Choisir une couleur des répertoires"
      android:dialogTitle="Couleur des répertoires" />
  </PreferenceCategory>
</PreferenceScreen>

Il suffit ensuite de déclarer l'activité dans le Manifest :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="sdz.chapitreTrois.explorateur"
  android:versionCode="1"
  android:versionName="1.0" >

  <uses-sdk
    android:minSdkVersion="7"
    android:targetSdkVersion="7" />

  <application
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name"
    android:theme="@style/AppTheme" >
    <activity
      android:name=".ExplorateurActivity"
      android:label="@string/title_activity_explorateur" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
    <activity
      android:name=".ExploreurPreference"
      android:label="@string/title_activity_exploreur_preference" >
    </activity>
  </application>
</manifest>

… puis de créer l'activité :

1
2
3
4
5
6
7
public class ExploreurPreference extends PreferenceActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    addPreferencesFromResource(R.xml.preference);
  }  
}

L'activité principale

Attributs

Voici les différents attributs que j'utilise :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
 * Représente le texte qui s'affiche quand la liste est vide
 */
private TextView mEmpty = null;

/**
 * La liste qui contient nos fichiers et répertoires
 */
private ListView mList = null;

/**
 * Notre adaptateur personnalisé qui lie les fichiers à la liste
 */
private FileAdapter mAdapter = null;

/**
 * Représente le répertoire actuel
 */
private File mCurrentFile = null;

/**
 * Couleur voulue pour les répertoires
 */
private int mColor = 0;

/**
 * Indique si l'utilisateur est à la racine ou pas
 * Pour savoir s'il veut quitter
 */
private boolean mCountdown = false;

/**
 * Les préférences partagées de cette application
 */
private SharedPreferences mPrefs = null;

Comme je fais implémenter OnSharedPreferenceChangeListener à mon activité, je dois redéfinir la méthode de callback :

1
2
3
4
5
6
7
/**
 * Se déclenche dès qu'une préférence a changé
 */
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
  mColor = sharedPreferences.getInt("repertoireColorPref", Color.BLACK);
  mAdapter.notifyDataSetInvalidated();
}

L'adaptateur

J'utilise un Adapter que j'ai créé moi-même afin d'avoir des items de la liste de différentes couleurs :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
 * L'adaptateur spécifique à nos fichiers
 */

private class FileAdapter extends ArrayAdapter<File> {
  /**
   * Permet de comparer deux fichiers
   *
   */
  private class FileComparator implements Comparator<File> {
    public int compare(File lhs, File rhs) {
      // Si lhs est un répertoire et pas l'autre, il est plus petit
      if(lhs.isDirectory() && rhs.isFile())
        return -1;
      // Dans le cas inverse, il est plus grand
      if(lhs.isFile() && rhs.isDirectory())
        return 1;

      // Enfin, on ordonne en fonction de l'ordre alphabétique sans tenir compte de la casse
      return lhs.getName().compareToIgnoreCase(rhs.getName());
    }           
  }

  public FileAdapter(Context context, int textViewResourceId, List<File> objects) {
    super(context, textViewResourceId, objects);
    mInflater = LayoutInflater.from(context);
  }

  private LayoutInflater mInflater = null;

  /**
   * Construit la vue en fonction de l'item
   */
  public View getView(int position, View convertView, ViewGroup parent) {
    TextView vue = null;

    if(convertView != null)
      // On recycle
      vue = (TextView) convertView;
    else
      // On inflate
      vue = (TextView) mInflater.inflate(android.R.layout.simple_list_item_1, null);

    File item = getItem(position);
    //Si c'est un répertoire, on choisit la couleur dans les préférences
    if(item.isDirectory())
      vue.setTextColor(mColor);
    else
      // Sinon, c'est du noir
      vue.setTextColor(Color.BLACK);

    vue.setText(item.getName());
    return vue;
  }

  /**
   * Pour trier rapidement les éléments de l'adaptateur
   */
  public void sort () {
    super.sort(new FileComparator());
  }
}

Méthodes secondaires

Ensuite, j'ai une méthode qui permet de vider l'adaptateur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/**
 * On enlève tous les éléments de la liste
 */

public void setEmpty() {
  // Si l'adaptateur n'est pas vide…
  if(!mAdapter.isEmpty())
    // Alors on le vide !
    mAdapter.clear();
}

J'ai aussi développé une méthode qui me permet de passer d'un répertoire à l'autre :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * Utilisé pour naviguer entre les répertoires
 * @param pFile le nouveau répertoire dans lequel aller
 */

public void updateDirectory(File pFile) {
  // On change le titre de l'activité
  setTitle(pFile.getAbsolutePath());

  // L'utilisateur ne souhaite plus sortir de l'application
  mCountdown = false;

  // On change le répertoire actuel
  mCurrentFile = pFile;
  // On vide les répertoires actuels
  setEmpty();

  // On récupère la liste des fichiers du nouveau répertoire
  File[] fichiers = mCurrentFile.listFiles();

  // Si le répertoire n'est pas vide…
  if(fichiers != null) {
    // On les ajoute à  l'adaptateur
    for(File f : fichiers)
      mAdapter.add(f);
    // Puis on le trie
    mAdapter.sort();
  }
}

Cette méthode est d'ailleurs utilisée par la méthode de callback onKeyDown :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean onKeyDown(int keyCode, KeyEvent event) {
  // Si on a appuyé sur le retour arrière
  if(keyCode == KeyEvent.KEYCODE_BACK) {
    // On prend le parent du répertoire courant
    File parent = mCurrentFile.getParentFile();
    // S'il y a effectivement un parent
    if(parent != null)
      updateDirectory(parent);
    else {
      // Sinon, si c'est la première fois qu'on fait un retour arrière
      if(mCountdown != true) {
        // On indique à l'utilisateur qu'appuyer dessus une seconde fois le fera sortir
        Toast.makeText(this, "Nous sommes déjà à la racine ! Cliquez une seconde fois pour quitter", Toast.LENGTH_SHORT).show();
        mCountdown  = true;
      } else
        // Si c'est la seconde fois, on sort effectivement
        finish();
    }
    return true;
  }
  return super.onKeyDown(keyCode, event);
}

Gestion de l'intent pour visualiser un fichier

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * Utilisé pour visualiser un fichier
 * @param pFile le fichier à visualiser
 */
private void seeItem(File pFile) {
  // On crée un intent
  Intent i = new Intent(Intent.ACTION_VIEW);

  String ext = pFile.getName().substring(pFile.getName().indexOf(".") + 1).toLowerCase();
  if(ext.equals("mp3"))
    i.setDataAndType(Uri.fromFile(pFile), "audio/mp3");
    /** Faites en autant que vous le désirez */

  try {
    startActivity(i);
    // Et s'il n'y a pas d'activité qui puisse gérer ce type de fichier
  } catch (ActivityNotFoundException e) {
    Toast.makeText(this, "Oups, vous n'avez pas d'application qui puisse lancer ce fichier", Toast.LENGTH_SHORT).show();
    e.printStackTrace();
  }
}

Les menus

Rien d'étonnant ici, normalement vous connaissez déjà tout. À noter que j'ai utilisé deux layouts pour le menu contextuel de manière à pouvoir le changer selon qu'il s'agit d'un répertoire ou d'un fichier :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Override
public boolean onCreateOptionsMenu(Menu menu) {
  getMenuInflater().inflate(R.menu.activity_explorateur, menu);
  return true;
}

@Override
public boolean onOptionsItemSelected (MenuItem item)
{
  switch(item.getItemId())
  {
    case R.id.menu_options:
      // Intent explicite
      Intent i = new Intent(this, ExploreurPreference.class);
      startActivity(i);
      return true;
  }
  return super.onOptionsItemSelected(item);
}

@Override
public void onCreateContextMenu(ContextMenu menu, View vue, ContextMenuInfo menuInfo) {
  super.onCreateContextMenu(menu, vue, menuInfo);

  MenuInflater inflater = getMenuInflater();
  // On récupère des informations sur l'item par apport à l'adaptateur
  AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;

  // On récupère le fichier concerné par le menu contextuel
  File fichier = mAdapter.getItem(info.position);
  // On a deux menus, s'il s'agit d'un répertoire ou d'un fichier
  if(!fichier.isDirectory())
    inflater.inflate(R.menu.context_file, menu);
  else
    inflater.inflate(R.menu.context_dir, menu);
}

@Override
public boolean onContextItemSelected(MenuItem item) {
  AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) item.getMenuInfo();
  // On récupère la position de l'item concerné
  File fichier = mAdapter.getItem(info.position);
  switch (item.getItemId()) {
    case R.id.deleteItem:
      mAdapter.remove(fichier);
      fichier.delete();
      return true;

    case R.id.seeItem:
      seeItem(fichier);
      return true;
  }
  return super.onContextItemSelected(item);
}

onCreate

Voici la méthode principale où se situent toutes les initialisations :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);

  // On récupère la ListView de notre activité
  mList = (ListView) getListView();

  // On vérifie que le répertoire externe est bien accessible
  if(!Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
    // S'il ne l'est pas, on affiche un message
    mEmpty = (TextView) mList.getEmptyView();
    mEmpty.setText("Vous ne pouvez pas accéder aux fichiers");
  } else {
    // S'il l'est, on déclare qu'on veut un menu contextuel sur les éléments de la liste
    registerForContextMenu(mList);

    // On récupère les préférences de l'application 
    mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
    // On indique que l'activité est à l'écoute des changements de préférences
    mPrefs.registerOnSharedPreferenceChangeListener(this);
    // On récupère la couleur voulue par l'utilisateur, par défaut il s'agira du rouge
    mColor = mPrefs.getInt("repertoireColorPref", Color.RED);

    // On récupère la racine de la carte SD pour qu'elle soit le répertoire consulté au départ
    mCurrentFile = Environment.getExternalStorageDirectory();

    // On change le titre de l'activité pour y mettre le chemin actuel
    setTitle(mCurrentFile.getAbsolutePath());

    // On récupère la liste des fichiers dans le répertoire actuel
    File[] fichiers = mCurrentFile.listFiles();

    // On transforme le tableau en une structure de données de taille variable
    ArrayList<File> liste = new ArrayList<File>();
    for(File f : fichiers)
      liste.add(f);

    mAdapter = new FileAdapter(this, android.R.layout.simple_list_item_1, liste);
    // On ajoute l'adaptateur à la liste
    mList.setAdapter(mAdapter);
    // On trie la liste
    mAdapter.sort();

    // On ajoute un Listener sur les items de la liste
    mList.setOnItemClickListener(new OnItemClickListener() {

      // Que se passe-t-il en cas de clic sur un élément de la liste ?
      public void onItemClick(AdapterView<?> adapter, View view, int position, long id) {
        File fichier = mAdapter.getItem(position);
        // Si le fichier est un répertoire…
        if(fichier.isDirectory())
          // On change de répertoire courant
          updateDirectory(fichier);
        else
          // Sinon, on lance l'item
          seeItem(fichier);
      }
    });
  }
}

Télécharger le projet

Améliorations envisageables

Quand la liste est vide ou le périphérique externe est indisponible

On se trouve en face d'un écran blanc pas très intéressant… Ce qui pourrait être plus excitant, c'est un message qui indique à l'utilisateur qu'il n'a pas accès à ce périphérique externe. On peut faire ça en indiquant un layout pour notre ListActivity ! Oui, je sais, je vous ai dit de ne pas le faire, parce que notre activité contient principalement une liste, mais là on pousse le concept encore plus loin. Le layout qu'on utilisera doit contenir au moins une ListView pour représenter celle de notre ListActivity, mais notre application sera bien incapable de la trouver si vous ne lui précisez pas où elle se trouve. Vous pouvez le faire en mettant comme identifiant à la ListView android:id="@android:id/list". Si vous voulez q'un widget ou un layout s'affiche quand la liste est vide, vous devez lui attribuer l'identifiant android:id="@android:id/empty". Pour ma correction, j'ai le XML suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?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"
  android:paddingLeft="8dp"
  android:paddingRight="8dp">

  <ListView android:id="@android:id/list"
    android:layout_width="fill_parent"
    android:layout_height="0dip"
    android:layout_weight="1"
    android:drawSelectorOnTop="false"/>

  <TextView android:id="@android:id/empty"
    android:layout_width="fill_parent"
    android:layout_height="0dip"
    android:layout_weight="1"
    android:text="@string/empty"/>
</LinearLayout>

Détection automatique du type MIME

Parce que faire une longue liste de « Si on a cette extension pour ce fichier, alors le type MIME, c'est celui-là » est quand même long et contraignant, je vous propose de détecter automatiquement le type MIME d'un objet. Pour cela, on utilisera un objet de type MimeTypeMap. Afin de récupérer cet objet, on passe par la méthode statique MimeTypeMap MimeTypeMap.getSingleton().

Petite digression pour vous dire que le design pattern singleton a pour objectif de faire en sorte que vous ne puissiez avoir qu'une seule instance d'un objet. C'est pourquoi on utilise la méthode getSingleton() qui renvoie toujours le même objet. Il est impossible de construire autrement un objet de type MimeTypeMap.

Ensuite c'est simple, il suffit de donner à la méthode String getMimeTypeFromExtension(String extension) l'extension de notre fichier. On obtient ainsi :

1
2
3
MimeTypeMap mime = MimeTypeMap.getSingleton();
String ext = fichier.getName().substring(fichier.getName().indexOf(".") + 1).toLowerCase();
String type = mime.getMimeTypeFromExtension(ext);

Détecter les changements d'état du périphérique externe

C'est bien beau tout ça, mais si l'utilisateur se décide tout à coup à changer la carte SD en pleine utilisation, nous ferons face à un gros plantage ! Alors comment contrer ce souci ? C'est simple. Dès que l'état du périphérique externe change, un broadcast intent est transmis pour le signaler à tout le système. Il existe tout un tas d'actions différentes associées à un changement d'état, je vous propose de ne gérer que le cas où le périphérique externe est enlevé, auquel cas l'action est ACTION_MEDIA_REMOVED. Notez au passage que l'action pour dire que la carte fonctionne à nouveau est ACTION_MEDIA_MOUNTED.

Comme nous l'avons vu dans le cours, il faudra déclarer notre broadcast receiver dans le Manifest :

1
2
3
4
5
6
7
<receiver android:name=".ExplorerReceiver"
  android:exported="false">
  <intent-filter> 
    <action android:name="android.intent.action.MEDIA_REMOVED" />
    <action android:name="android.intent.action.MEDIA_MOUNTED" />
  </intent-filter>
</receiver>

Ensuite, dans le receiver en lui-même, on fait en sorte de viser la liste des éléments s'il y a un problème avec le périphérique externe, ou au contraire de la repeupler dès que le périphérique fonctionne correctement à nouveau. À noter que dans le cas d'un broadcast Intent avec l'action ACTION_MEDIA_MOUNTED, l'intent aura dans son champ data l'emplacement de la racine du périphérique externe :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class ExplorerReceiver extends BroadcastReceiver {
  private ExplorateurActivity mActivity = null;

  public ExplorerReceiver(ExplorateurActivity mActivity) {
    super();
    this.mActivity = mActivity;
  }

  @Override
  public void onReceive(Context context, Intent intent) {
    if(intent.getAction().equals(Intent.ACTION_MEDIA_REMOVED))
      mActivity.setEmpty();
    else if(intent.getAction().equals(Intent.ACTION_MEDIA_MOUNTED))
      mActivity.updateDirectory(new File(intent.getData().toString()));
  }
}