Comment afficher une image distante dans une notification Android

Il est très simple sur Android de créer des notifications contenant un contenu personnalisé et non pas un template de base. Cela se complique un peu lorsque l'on souhaite afficher une image qui n'est pas présente dans le package de base mais provient d'internet. Dans cet article nous verrons la technique que j'utilise.

notifs.png 

 TL;DR;

 Les URIs des images des notifications pointent vers un ContentProvider qui fournit les fichiers après les avoir téléchargés en local.

 

Création d'une notification avec contenu personnalisé

Une notification avec un contenu personnalisé passe par l'utilisation d'une RemoteView créé de manière très similaire à celle utilisée pour un Widget Android : création de la RemoteView et application des différentes valeurs aux éléments visuels. Voici un exemple d'une notification affichant la diffusion d'un nouvel épisode :

var remoteViews = new RemoteViews(context.PackageName, 
       Resource.Layout.notification_airedEpisode);
remoteViews.SetTextViewText(Resource.Id.notification_Title, title);
remoteViews.SetTextViewText(Resource.Id.notification_Subtitle, body);
remoteViews.SetTextViewText(Resource.Id.notification_Subsubtitle, when);

 

On peut aussi assigner une image locale (une ressource donc) à un composant ImageView :

remoteViews.SetImageViewResource(
     Resource.Id.notification_Image, 
     Resource.Drawable.MonImageLocale);

 

Il existe aussi la méthode SetImageUri pour charger l'ImageView avec une URI mais il y a un problème : cela ne fonctionne qu'avec des URL locales comme l'indique la documentation

 

Créer un ContentProvider pour fournir les images

Un ContentProvider est un composant Android capable de fournir du contenu à toutes les applications de l'OS via une URL. Généralement cela est utilisé pour manipuler des données de la même façon que l'on interrogerait une base de données avec des opérations CRUD mais dans notre cas, nous allons fournir une image à notre notification après l'avoir téléchargée dans le ContentProvider! Chaque contenu est identifié de manière unique au moyen d'une URL. 

 

La création d'un ContentProvider passe par plusieurs étapes :

  • Création d'une classe dérivant de ContentProvider que l'on pense à déclarer dans le manifest de manière "exportable" et "visible".
  • Définition d'une autorité = une chaîne de caractères identifiant votre fournisseur de contenu de manière unique.
  • Définition d'un mime-type pour le type de contenu retourné.

Plusieurs méthodes seront obligatoires du fait de notre héritage de ContentProvider mais nous pouvons nous contenter de jeter une exception UnsupportedOperationException.

 La structure de base de notre classe aura donc cette définition : 

[ContentProvider(new string[] { AUTHORITY },
	Enabled = true, Exported = true)]
public class ImageContentProvider : ContentProvider
{
    public const string AUTHORITY 
		= "com.monapp.notifImagesProvider";
    public const string BASE_PATH = "notifImages";

    // MIME types used for getting the image
    public const string IMAGE_MIME_TYPE 
	= ContentResolver.CursorItemBaseType 
	
	+ "/vnd.com.monapp.notifImage";

    public static readonly Uri CONTENT_URI 
		= Uri.Parse("content://" + AUTHORITY + "/" + BASE_PATH);
        

 
    public override string GetType(Uri uri)
    {
        return IMAGE_MIME_TYPE; // single item
    }

    public override bool OnCreate()
    {
        // everythings is ok :)
        return true;
    }

    public override ICursor Query(Uri uri, string[] projection, 
	string selection, string[] selectionArgs, string sortOrder)
    {
        // Mandatory but not used
        throw new Java.Lang.UnsupportedOperationException();
    }

    public override int Delete(Uri uri, string selection,
	string[] selectionArgs)
    {
        // Mandatory but not used
        throw new Java.Lang.UnsupportedOperationException();
    }

    public override Uri Insert(Uri uri, ContentValues values)
    {
        // Mandatory but not used
        throw new Java.Lang.UnsupportedOperationException();
    }

    public override int Update(Uri uri, ContentValues values,
        string selection, string[] selectionArgs)
    {
        // Mandatory but not used
        throw new Java.Lang.UnsupportedOperationException();
    }
}

 

Fournir les images via le ContentProvider 

Pour fournir les images, je vais surcharger la méthode OpenFile pour télécharger l'image et la retourner sous la forme d'un objet ParcelFileDescriptor. On peut considérer qu'il s'agit d'un objet simple décrivant un fichier à partir de son chemin.

Dans le reste de mon application, je consomme, tout comme Jérôme qui en parlait dans son précédent article, la librairie Picasso pour télécharger et afficher les images. Sous le capot, cela utilise le composant OkHttpDownloader et je vais donc le ré-utiliser ici et bénéficier éventuellement de son cache. 

Je fais donc le traitement suivant :

  1. Récupérer l'URL de l'image à télécharger. Pour cela je récupère le dernier segment du paramètre d'entrée que je nettoie avec "System.Uri.UnescapeDataString".
  2. Je créé un chemin unique vers un fichier unique correspondant à cette URL d'entrée dans le cache temporaire de mon application.
  3. Si le fichier existe, je le retourne directement.
  4. Si le fichier n'existe pas, je le télécharge et retourne ensuite le chemin vers le fichier.
  5. Si je n'arrive pas à télécharger l'image, je lève une exception java de type FileNotFoundException.

Je saupoudre aussi mon code de lock en fonction de l'URL de l'image à télécharger pour éviter de le faire plusieurs fois en parallèle. Je ne mets pas le code ici vu qu'il ne casse pas trois pattes à un canard et que l'utilisation d'un dictionnaire n'est pas le sujet de l'article :)

public override ParcelFileDescriptor 
OpenFile(Uri uri, string mode)

try
{
  var seg = uri.PathSegments;
  var url = System.Uri.UnescapeDataString(seg.LastOrDefault());
  if (string.IsNullOrEmpty(url))
  {
    throw new Java.IO.FileNotFoundException();
  }
 
  // Récupérer l'URL de l'image à télécharger.
  var cacheFileName = Path.GetFileName(url);
  string path = Path.Combine(
	Path.GetTempPath(), 
	System.Uri.EscapeDataString(cacheFileName));

  // Si l'image n'existe pas : télécharger
  if (false == File.Exists(path))
  {
    lock (GetQueryLock(url))
    {
      // télécharger entre-temps
      if (false == File.Exists(path))
      {
        var resolver = Context.ContentResolver;
        var initCont = Application.SynchronizationContext;
       
        var dlder = new OkHttpDownloader(Context);
        var response = dlder.Load(Uri.Parse(url), 0);

        using (var ostream = File.Open(path, FileMode.Create, 
									         FileAccess.ReadWrite))
        {
          response.InputStream.CopyTo(ostream);
        }

        dlder.Shutdown();
      }
    }
  }

  var ovf = ParcelFileDescriptor.Open(
  new Java.IO.File(path), ParcelFileMode.ReadOnly);
  
  return ovf;
}
catch (Java.IO.FileNotFoundException)
{
  throw;
}
catch (System.Exception e)
{
  throw new Java.IO.FileNotFoundException();
}
}   

 

Eviter de figer le centre de notification Android tout entier !

Le système fonctionne bien en l'état mais il a un vilain défaut : pendant le téléchargement des images, le centre de notification est figé et inutilisable. On va donc effectuer le téléchargement dans un Thread à part afin d'éviter ce vilain défaut.

Aussi, je ne veux pas qu'un téléchargement prenne plus d'une seconde. Si c'est le cas, on affichera un placeholder car cela doit arriver peu souvent : les images utilisées par mon application seront souvent les mêmes et donc il y a de grandes chance qu'elles soient en cache.

Pour mettre en place cela je vais donc :

  1. Créer une task d'attente minimum comme décrit dans ce précédent article.
  2. Lancer le téléchargement dans une task à part.
  3. Attendre la fin d'une des deux task : le centre de notifications pourra potentiellement être figé pendant cette durée.
  4. Retourner soit l'URL soit lever une exception FileNotFoundException.

Voici la partie du code correspondant à cet algorithme :

var delay = Task.Delay(1000);
var dlTask = Task.Run(() =>
{
  try
  {
    var okHttpDownloader = new OkHttpDownloader(Context);
    var downloaderResponse = okHttpDownloader.Load(Uri.Parse(url), 0);

    using (var ostream = File.Open(path, FileMode.Create,
									FileAccess.ReadWrite))
    {
      downloaderResponse.InputStream.CopyTo(ostream);
    }

    okHttpDownloader.Shutdown();
  }
  catch  { }
});

Task.WaitAny(delay, dlTask);

if (dlTask.IsCompleted == false && false == File.Exists(path))
{
  throw new Java.IO.FileNotFoundException();
}

 

Utiliser le ContentProvider dans notre notification

Il suffit pour cela de créer une URL qui appellera notre ContentProvider en lui indiquant l'url de l'image ciblée. On assigne ensuite l'URI au composant ImageView :

var uri = Android.Net.Uri.WithAppendedPath(
    ImageContentProvider.CONTENT_URI, 
    Uri.EscapeDataString(incoming.Url));

remoteViews.SetImageViewUri(Resource.Id.notification_Image, uri);

 

Prévenir un consommateur que le contenu a changé.

Le content Provider permet d'indiquer à un consommateur qu'un contenu pointé par une URL a évolué. Il faut pour cela appeler la méthode NotifyChange du resolver  en passant l'URI "mise à jour" en prenant soin de rester sur le contexte d'appel :

// Sauvegarde du contexte d'appel
var initCont = Application.SynchronizationContext;


....

initCont.Post(ignored =>
{
    resolver.NotifyChange(uri, null, true);
}, null);

 

Dans les faits, je n'ai pas réussi à le faire fonctionner : cela peut tout simplement vouloir dire que le consommateur (le centre de notification) ne s'abonne pas aux changements.

 

Pourquoi je n'utilise pas la librairie Picasso ?

Mon premier réflexe fût d'utiliser les méthodes de chargement d'image dans une RemoteView donnée par Picasso lui-même mais cela a quelques effets de bord :

  1. Cela affiche la notification dès que l'image est chargée ... pas très compatible avec des notifications planifiées.
  2. Cela charge toutes les images au moment de la création : un gros pic de téléchargement pour des notifications qui ne seront peut être pas affichées si l'utilisateur désinstalle l'application, etc.

Comment faites-vous ?

Je trouve cela extrêmement compliqué pour un besoin que je pense très courant : comment faites-vous ?

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus