Loupe

Android : intégrer le calendrier local natif du téléphone via un SyncProvider

Dans cet article nous verrons comment votre application Android peut écrire dans le calendrier local de votre téléphone. Peut importe l'application "calendrier" utilisée, vous devriez voir apparaître les événements de votre application sous la forme d'un nouveau calendrier. Dans notre cas, les événements créés seront en lecture seule. 

Voici un exemple de l'affichage obtenu sur Google Calendar dans une application de gestion de séries :

57289498_2174197726029235_2243587668711571456_o.jpg57052216_2174197659362575_8557037118197596160_n.jpg

Attention, cet article ne présente que les bases, si vous voulez quelque chose de vraiment complet je vous conseille inwink :) !

Mise en place théorique

Tout repose sur l'idée de base d'Android de proposer des fournisseurs de données "génériques" : les ContentProviders. Chaque ContentProvider peut être accédé depuis les autres applications et manipulé à l'aide de différentes méthodes de lecture, d'insertions, modification ou suppression. Chaque ContentProvider est une sorte de base de données sur votre téléphone exposée aux autres applications. Chaque ressource/donnée est identifiée de manière unique par une Uri (dont c'est bien le but initial quand même :)). Je ne sais pas si vous vous en souvenez mais nous avions déjà créé un ContentProvider afin de mettre des images distantes (coucou l'internet mondial) dans des notifications.

Pour accéder aux données on utilise une classe ContentResolver présente sur les différents Contexts présents sur vos activités Android. Les calendriers et les événements de votre téléphone Android sont ainsi exposés à votre application sous la forme d'un ContentProvider et nous allons utiliser un ContentResolver pour les manipuler via les bonnes URLs. Voici un petit schéma extrait de la documentation officielle :

content-provider-interaction.png

Votre téléphone Android possède ainsi une "base de données" représentant les calendriers et leurs événements. Toutes les applications "calendrier" que vous utilisez (Google Calendar, Samsung Calendar, Inwink Calendar, etc.) ne font donc qu'afficher les données de ce calendrier.

Demande des permissions

Pour pouvoir écrire dans le calendrier natif, il va falloir avoir 2 permissions : lire le calendrier et écrire dans le système de calendrier du téléphone. La première étape consiste à le déclarer dans le manifest de l'application : 

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

La demande des permissions se passe alors en 2 étapes :

  1. Vous faites la demande via la méthode RequestPermissions en indiquant les permissions souhaitées et en spécifiant un code de demande (vous choisissez de manière arbitraire la valeur souhaitée).
  2. Vous surchargez la méthode OnRequestPermissionResult de votre activité et il faut vérifier si l'accord a été donné.

Voici le code des deux méthodes incriminées :

internal static readonly int REQUEST_CALENDAR = 2222;

internal static bool AskPemissionIfNeeded(Activity activity)
{
 Android.Support.V4.App.ActivityCompat.RequestPermissions(
        activity,
        new[] {
            Manifest.Permission.WriteCalendar,
            Manifest.Permission.ReadCalendar },
        REQUEST_CALENDAR);
    return false;
}


public override void OnRequestPermissionsResult(
    int requestCode,
    string[] permissions,
    [GeneratedEnum] Permission[] grantResults)
{
    if (requestCode == REQUEST_CALENDAR)
    {
        if ((grantResults.Length == 1)
            && (grantResults[0] == Permission.Granted))
        {
            // youhou il a dit oui !!
        }
    }
    else
    {
        base.OnRequestPermissionsResult(
            requestCode, permissions, grantResults);
    }
}

Ensuite, à tout moment vous pouvez vérifier si l'utilisateur ne vous as pas déjà donné ces permissions avec le code ci-dessous. N'oubliez pas de le faire avant chaque manipulation du calendrier car sur Android l'utilisateur peut vous révoquer des accès à tout moment.

var permissionGranted =
ContextCompat
 .CheckSelfPermission(context, Manifest.Permission.WriteCalendar)
        == (int)Permission.Granted

 && ContextCompat
 .CheckSelfPermission(context, Manifest.Permission.ReadCalendar) 
        == (int)Permission.Granted; 

Récupération des calendriers existants

Pour travailler avec les calendrier natifs nous allons utiliser le ContentResolver présent sur chaque context. Celui-ci permet de faire une requête sur ses données en spécifiant la cible (quelles données) sous la forme d'une URI ainsi que les colonnes que l'on souhaite lire.

Afin d'avoir les meilleures performances on essayera de lire le moins de colonnes possible. Dans notre exemple, nous allons lire l'identifiant du calendrier, son nom affiché (surtout à des fins de debug) ainsi que le nom du compte associé. Le résultat d'une requête est un curseur sur lequel on peut itérer pour trouver un calendrier en particulier : ici on cherche celui que l'on aurait pu déjà créer en se basant sur le nom du compte justement.

private static int GetCalendarId(Context context)
{
    if (context == null)
    {
        return -1;
    }
 
    string[] calendarsProjection = {
        CalendarContract.Calendars.InterfaceConsts.Id,
        CalendarContract.Calendars.InterfaceConsts.CalendarDisplayName,
        CalendarContract.Calendars.InterfaceConsts.AccountName,
    };

    var calendarsUri = CalendarContract.Calendars.ContentUri;
    var cursor = context.ContentResolver.Query(calendarsUri,
        calendarsProjection, null, null, null);

    for (int i = 0; i < cursor.Count; i++)
    {
        cursor.MoveToPosition(i);
        string calAccountName = cursor.GetString(
            cursor.GetColumnIndex(calendarsProjection[2]));
        if (calAccountName?.Equals(CALENDAR_ACCOUNT_NAME) == true)
        {
            int calId = cursor.GetInt(
                cursor.GetColumnIndex(calendarsProjection[0]));

            return calId;
        }
    }
    
    return -1;
}

Le système de query permet de créer des filtres directement plutôt que de parcourir la liste entière. Dans notre cas cela n'est pas nécessaire car il y a probablement que peu de calendrier sur le téléphone et pas de problème de performance auquel s'attendre.

Création d'un calendrier

La création d'un calendrier passe cette fois-ci par la méthode Insert du ContentResolver. On instancie un objet de type ContentValues, une sorte de dictionnaire, et on le remplit avec les différentes propriétés que l'on souhaite donner à notre calendrier. 

La liste complète des propriétés possibles est présente sous la forme de constantes de la classe CalendarContract.Calendars.InterfaceConsts mais quelques-unes sont plus importantes que les autres :

  • AccountName : le nom du compte associé aux calendriers de votre app : choisissez quelque chose d'unique, propre à votre application.
  • AccountType : le type de calendrier créé. Dans notre cas c'est un calendrier local.
  • Name : le nom que vous souhaitez lui donner. Pratique si vous voulez créer et différencier plusieurs calendriers pour votre app.
  • CalendarAccessLevel : le niveau d'accès que vous souhaiter donner aux utilisateurs sur les événements créés. Dans notre cas, cela sera lecture seule.
var cv = new ContentValues();
cv.Put(CalendarContract.Calendars.InterfaceConsts.AccountName, CALENDAR_ACCOUNT_NAME);
cv.Put(CalendarContract.Calendars.InterfaceConsts.AccountType, CalendarContract.AccountTypeLocal);
cv.Put(CalendarContract.Calendars.Name, "Event-intelligence software");
cv.Put(CalendarContract.Calendars.InterfaceConsts.CalendarDisplayName, "inwink");
cv.Put(CalendarContract.Calendars.InterfaceConsts.CalendarColor, color);
cv.Put(CalendarContract.Calendars.InterfaceConsts.OwnerAccount, CALENDAR_ACCOUNT_NAME);
cv.Put(CalendarContract.Calendars.InterfaceConsts.CalendarAccessLevel, (int)CalendarAccess.AccessRead);
cv.Put(CalendarContract.Calendars.InterfaceConsts.Visible, true);
cv.Put(CalendarContract.Calendars.InterfaceConsts.SyncEvents, true);

Afin de créer le calendrier, il faut indiquer que nous sommes un "fournisseur de synchronisation". Cela se fait en ajoutant des paramètres à l'URI utilisée pour spécifier où nous allons insérer le calendrier à l'aide du ContentResolver. Heureusement, le SDK nous donne tout ce qu'il faut pour le faire facilement :

var uri = CalendarContract.Calendars.ContentUri.BuildUpon()
       .AppendQueryParameter(CalendarContract.CallerIsSyncadapter, "true")
       .AppendQueryParameter(CalendarContract.Calendars.InterfaceConsts.AccountName, CALENDAR_ACCOUNT_NAME)
       .AppendQueryParameter(CalendarContract.Calendars.InterfaceConsts.AccountType, CalendarContract.AccountTypeLocal)
       .Build();
       
var calendarUri = context.ContentResolver.Insert(uri, contentValues);

En retour nous avons une URI permettant de cibler notre calendrier créé ou null s'il n'est pas créé. Cela peut se produire si certains paramètres passés ne sont pas jugés corrects.

Récupération des événements

La récupération des événements est encore une fois une Query mais utilisant une Uri ciblant les events du calendrier dont on connait maintenant l'identifiant. Pour cela on crée une sélection sur l'identifiant de calendrier et on passe l'id du calendrier en valeur du paramètre correspondant aux arguments de sélection. C'est peut être dur à lire / décrire mais le code est plutôt lisible :

private static
    List<(int eventId, string episodeId)>
    RetrieveExistingCalendarEvents(Context context, int calendarId)
{
    string[] eventsProjection = {
        CalendarContract.Events.InterfaceConsts.Id,
        CalendarContract.Events.InterfaceConsts.CalendarId,
        CalendarContract.Events.InterfaceConsts.SyncData1,
        };
    var selection = "(" + CalendarContract.Events.InterfaceConsts.CalendarId + " = ?)";
    string[] selectionArgs = new[] { calendarId.ToString() };
    var cursor = context.ContentResolver.Query(
        CalendarContract.Events.ContentUri, 
        eventsProjection, 
        selection, 
        selectionArgs, 
        null);

    var calendarEventsInfos = new List<(int eventId, string episodeId)>();

    for (int i = 0; i < cursor.Count; i++)
    {
        cursor.MoveToPosition(i);
        int calendarIdOfEvent = cursor.GetInt(
            cursor.GetColumnIndex(eventsProjection[1]));
        int eventId = cursor.GetInt(
            cursor.GetColumnIndex(eventsProjection[0]));
        string episodeId = cursor.GetString(
            cursor.GetColumnIndex(eventsProjection[2]));
        calendarEventsInfos.Add((eventId, episodeId));
    }

    return calendarEventsInfos;
}

On en profite pour créer une liste de tuples nommés (coucou C#7) avec les informations lues dans le ContentProvider. Vous remarquerez que j'utilise la colonne SyncData1 : cette dernière permet de stocker des données spécifiques au fournisseur de synchronisation que nous sommes. Dans le monde réel, je l'utilise pour stocker l'identifiant fonctionnel de l'épisode correspondant à l'événement.

Création d'un événement

La création d'un événement est encore une fois toute bête : on instancie un ContentValues et on lui insère les propriétés que l'on souhaite avoir sur notre événement. Pour cela, nous sommes aidés de la classe CalendarContract.Events.InterfaceConsts qui porte les constantes importantes :

  • CalendarId : le calendrier où on insère l'événement.
  • Title : le titre de l'événement.
  • Description : le contenu de l'événement.
  • AllDay : événement d'une journée entière ou non.
  • EventTimeZone et EventEndTimeZone : fuseau horaire de début et de fin de l'événement. À savoir que TimeZoneInfo.Local.DisplayName retourne la timezone du téléphone.
  • DtStart et DtEnd : les dates/heures de début et fin exprimées en millisecondes. 

On utilise ensuite une Uri où on indique que l'on est bien un fournisseur de synchronisation (pour utiliser les champs SyncDataXXX) :

var eventDate = GetDateTimeMS(dateDebut);
var eventDateEnd = GetDateTimeMS(dateFin);

var values = new ContentValues();
values.Put(CalendarContract.Events.InterfaceConsts.CalendarId, calId);
values.Put(CalendarContract.Events.InterfaceConsts.Title, eventTitle);
values.Put(CalendarContract.Events.InterfaceConsts.SyncData1, episode.Id);
values.Put(CalendarContract.Events.InterfaceConsts.Dtstart, eventDate);
values.Put(CalendarContract.Events.InterfaceConsts.Dtend, eventDateEnd);
values.Put(CalendarContract.Events.InterfaceConsts.AllDay, false);
values.Put(CalendarContract.Events.InterfaceConsts.HasAlarm, false);
values.Put(CalendarContract.Events.InterfaceConsts.EventTimezone, zone);
values.Put(CalendarContract.Events.InterfaceConsts.EventEndTimezone, zone);

var uri = CalendarContract.Events.ContentUri.BuildUpon()
         .AppendQueryParameter(CalendarContract.CallerIsSyncadapter, "true")
         .AppendQueryParameter(CalendarContract.Calendars.InterfaceConsts.AccountName, CALENDAR_ACCOUNT_NAME)
         .AppendQueryParameter(CalendarContract.Calendars.InterfaceConsts.AccountType, CalendarContract.AccountTypeLocal)
         .Build();

var created = cr.Insert(uri, values);

 

Le bon code du calcul du nombre de millisecondes n'est pas forcément évident à trouver mais il est au final tout bête : 

internal static readonly DateTime DateTime1970 = new DateTime(1970, 1, 1);

private long GetDateTimeMS(DateTime date)
{
);
    var comapare = 
        (long)(date.ToUniversalTime() - DateTime1970)
        .TotalMilliseconds;

    return comapare;
}

Modification d'un événement

Il peut être tentant à chaque passe de synchronisation de supprimer tous les événements créés pour les reconstruire intégralement : c'est une mauvaise idée. Premièrement c'est extrêmement lent et deuxièmement si l'utilisateur a personnalisé l'événement depuis son application calendrier, l'info serait perdue. 

Nous allons donc modifier proprement un événement de manière assez simple : on crée un ContentValues et on lui assigne les différentes propriétés que l'on souhaite modifier et uniquement celles-ci. On construira l'Uri de l'événement ciblé en y ajoutant son identifiant. Je ne vous montre que la construction de l'Uri vu que vous savez maintenant remplir un ContentValues :) : 

var baseUri = CalendarContract.Events.ContentUri.BuildUpon()
    .AppendQueryParameter(CalendarContract.CallerIsSyncadapter, "true")
     .AppendQueryParameter(CalendarContract.Calendars.InterfaceConsts.AccountName, CALENDAR_ACCOUNT_NAME)
     .AppendQueryParameter(CalendarContract.Calendars.InterfaceConsts.AccountType, CalendarContract.AccountTypeLocal).Build();
var uri = ContentUris.WithAppendedId(baseUri, calendarEventInfos.Value.eventId);
var updated = cr.Update(uri, values, null, null);

 

Suppression d'éléments

La suppression d'un élément est vraiment toute bête : on construit l'Uri qui le cible et on appelle la méthode Delete sur le ContentResolver. Par exemple pour supprimer un calendrier dont on connaît l'identifiant :

var deleteUri = ContentUris.WithAppendedId(
   CalendarContract.Calendars.ContentUri, 
   calId);

context.ContentResolver.Delete(deleteUri, null, null);

 

Happy coding :)

 

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus