🐈

【過去Blogからの移行記事】ical4jで取り込んだデータをAndroidのCalendar Providerに流し込む

2022/09/18に公開

表題の件について、拙作のアプリ「Gcal Importer」での実装を、自分の頭を整理するついでにかいつまんで書き出してみたいと思います。

対象source: Gcal_Importer -

【前提】
前回の記事で書いたようにして、iCalendar形式のデータ(以後ICSデータと呼ぶ)を読み込んで、sort済みのデータをModelクラスに持たせた状態であることを前提として、以後の記事を書きます。

【ShowEventListアクティビティで行っていること】

  1. AndroidのContenProviderに対して、ICSデータのImport先となるカレンダーの新規作成を行う。
  2. ICSデータ内の各イベントデータと同じものが、AndroidのContentProviderの持つカレンダーイベントにあるかどうかチェックする。同一性のチェックはイベントのUIDの比較だけで行っている(TitleやDescriptionに差異があるかどうかは見ていない)。
  3. 2.のチェックによってAndroidのCalendar Provider側に存在しなかったデータのみ、1.で作成したカレンダーへ順次登録していく。
  4. 本アプリが1.で作成したカレンダーを選択削除する機能も用意している。

「カレンダー作成」「イベントUIDの比較」「イベントの登録」「カレンダー削除」といった要求処理は、Calendar Providerに対してごく基本的なSQL文を組み合わせて行いますが、直接SQL文を書くのではなく、AndroidのContentResolverの持つ「insert」「delete」「query」といったメソッドにSQL文のパーツを渡して実行させるという独特なやり方になります。
本アプリでは、ShowEventListActivityにQueryToProviderという内部クラスを自作して機能毎にメソッドを用意し、それらにSQL文のパーツ作成とContentResolverのメソッド呼び出しを行わせています。

Androidの端末ローカル内に新規カレンダーを作成する

最初に、「ICSデータのImport先となるカレンダーの新規作成処理」を例にして流れを記述します(省略部分はソースを参照ください)。なお、当該箇所は特に下記の記事を参考にして実装を進めています。そちらもご参照ください。

さて、当アプリでは下記のようにして、自作の内部クラスQueryToProviderをインスタンス化してcreateNewCalendar()という自作メソッドを呼び出しています。

public class ShowEventListActivity extends Activity {
    // ~~変数宣言部分~~
    // Providerに対してdeleteやinsertやqueryなどを行うメソッドだけを持つ内部クラス
    QueryToProvider queryClass;
    // ~~省略~~

    // ~~onCreateメソッド内~~
    queryClass = new QueryToProvider(); // インスタンス化しておく
    // ~~省略(onCreateを抜ける)~~

    // ~~ボタンやメニュー等からカレンダー新規作成を行うメソッドを呼び出す~~
    queryClass.createNewCalendar(
          model.calendar.getProperties().getProperty("X-WR-CALNAME").getValue()
        , model.calendar.getProperties().getProperty("X-WR-CALNAME").getValue()
        , Color.rgb(0, 0, 0) // 色はとりあえず真っ黒を指定
        , model.timezone.getID()
    );

自作したcreateNewCalendar()メソッドに次の4つの引数を渡しておいて、後ほどContentProviderへinsertする時に使います。

  • String calendarName (カレンダー名。内部データとして使うっぽい)
  • String displayName (カレンダーの表示名。スマホ利用者が目にするもの)
  • int color (カレンダーの色指定に使う数値)
  • String timezone (カレンダーのタイムゾーンID。「Asia/Tokyo」とか)

第一、第二引数ですが、これらはGoogleカレンダーからエクスポートしたICSファイルをエディタで開いて目で読んで、それっぽいプロパティを判断して割り当てたものです。
第三引数、第四引数は、後ほどContentResolverに渡す時に従うべきフォーマットに合わせました。カレンダーの色はAndroidのColorクラスの持つrgb()メソッドを使って数値を得ます。

以上の引数を使って、createNewCalendar()メソッドでは次のような処理を行っています。

//内部クラスQueryToProvider内:
    int createNewCalendar(String calendarName, String displayName, int color, String timezone) {
        // 本アプリ独自のカレンダー名にする
        calendarName = calendarName + "_" + "OriginalString";
        ContentResolver contentResolver = getContentResolver();
        ContentValues calVal = new ContentValues();
        String ownerStr = "owner_" +  "OriginalString";
        if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            calVal.put(Calendars.ACCOUNT_NAME, appName); // 便宜的に本アプリのアプリ名を使っている
            calVal.put(Calendars.ACCOUNT_TYPE, packageName); // 便宜的に本アプリのパッケージ名を使っている。
            // 〜〜省略〜〜
            calVal.put(Calendars.OWNER_ACCOUNT, ownerStr);
        } else {
            calVal.put("_sync_account", appName);
            calVal.put("_sync_account_type", packageName);
            // 〜〜省略〜〜
            calVal.put("ownerAccount", ownerStr);
        }
        // API 14以降はCalendar Providerへの書き込みの際にSyncAdapterを使わなければならない項目がある。
        // そのため、それ用にUriをbuildするメソッド asSyncAdapter() 及び getCalProvider() を用意している。後述。
        result = contentResolver.insert(asSyncAdapter(getCalProvider(), appName, packageName), calVal).toString();
    }

// 以下2つはQueryToProviderの外部で定義してあるメソッド(つまり外部クラスのメソッド)

    /* API 14からCalendar ProviderのURI取得方法が刷新されたので、それに合わせたURIを返すメソッド */
    private Uri getCalProvider() {
        if(android.os.Build.VERSION.SDK_INT $gt;= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            return Calendars.CONTENT_URI;
        } else {
            return Uri.parse("content://com.android.calendar/calendars");
        }
    }
    /*
     * ContentProviderへのクエリ実行に使用するUriを生成するのに使うメソッド。
     * API 14以降は(CALLER_IS_SYNCADAPTER,"true")をqueryにappendする必要があるため、場合分けしている。
     */
    private Uri asSyncAdapter(Uri uri, String account, String accountType) {
        if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            return uri.buildUpon()
                    .appendQueryParameter(android.provider.CalendarContract.CALLER_IS_SYNCADAPTER,"true")
                    .appendQueryParameter(Calendars.ACCOUNT_NAME, account)
                    .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build();
        } else {
            return uri.buildUpon()
                    .appendQueryParameter("_sync_account", account)
                    .appendQueryParameter("_sync_account_type", accountType).build();
        }
     }

ContentResolverクラスのinsertやdeleteやqueryといったメソッドには、第一引数として「どのContentProviderを対象とするのかを示すUri」を渡す必要がありますが、対象がCalendarの場合はAPI 14を境にしてこの指定方法が変わるので、上記のような2つのメソッド asSyncAdapter() 及び getCalProvider() を用意してContentResolverが受け取るUriをAPI Levelに応じて適宜生成しています。

ICSデータとCalendar Providerとのイベント比較

ical4jのcalendarオブジェクトからはイベントデータのListをComponentListオブジェクトとして取得できますが、本アプリの目的である比較処理の際には、処理するデータ数が多い場合に備えてAsyncTaskというAndroid独特の非同期処理の仕組みを利用します。
恥ずかしながら作者が未熟なため、ComponentList型のままAsyncTaskに渡すうまいやり方を思いつかず、ArrayListに詰めなおして渡しています。

    // 
    ComponentList components = model.calendar.getComponents("VEVENT");

    // ArrayListに詰め直す処理
    ArrayList<VEvent> eventList = new ArrayList<VEvent>();
    VEvent event;
    for(Object obj : components) { // 一度ArrayListに入れて、後で配列にする(AsyncTaskの引数にするため)
        if( obj instanceof VEvent) {
            event = (VEvent)obj;
            eventList.add(event);
        }
    }

そうして作成したArrayListオブジェクトを、AsyncTaskを継承した内部クラスCompareAsyncのメソッドdoInBackground() に渡して、UIスレッドと非同期に比較処理を行っています。

    protected Void doInBackground(VEvent... eventArray) {
        String selection = null;
        String[] selectionArgs = null;
        Cursor cursor = null;
        for(VEvent event : eventArray){
            String uid = event.getUid().getValue();
            // EventsProviderから、EventのUIDがICS側のuidと一致するデータだけを抽出する為に、SQL文の条件を組み立てる
            if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
                selectionArgs = new String[] { uid };
                selection = "(" + CalendarContract.Events.SYNC_DATA1 + " = ?)";
            } else { // API 14未満の場合、URLに含まれているuidと比較する為に必要部分を切り出してlike句で%検索する。
                selectionArgs = new String[] { "%/" + uid.split("@")[0] };
                selection = "( _sync_id like ? ) ";
            }
            cursor = getContentResolver().query(getEventProvider(), null, selection, selectionArgs, null);
            if(cursor != null) { // cursorがnullであってはならない。(Uriが間違っている時などにnullとなったような気がする)
                if(cursor.moveToFirst()) { // SQLの検索に1件だけHitすることを期待しているコード
                    do {
                        // 下記の変数 bothExist は、外部クラスで予め定義してあるintの変数です。
                        bothExist++; // ICSデータと端末ローカルカレンダーの双方にあるEventなので、bothExistをincrementする
                    } while (cursor.moveToNext());
                }
                cursor.close();
            } else {
                Log.e("compareToCalendarProvider", "cursor is null.");
            }
        }
        return null;
    }

この比較処理ですが、下記の事情によって、AndroidのAPI Levelが14以上と14未満とで大きくやり方が変わります。

  • まず、Android API Level 14以上のCalendar Providerでは、EventのUIDを"sync_data1"というカラムに持たせているもよう。(このカラム名は、CalendarContract.Events.SYNC_DATA1 という定数から取り出せる)
  • しかしAndroid API Level 14未満のCalendar Providerでは、当該の値を直に格納しているカラムが無い。

このため、API 14未満の場合は、AndroidのCalendar Providerの持つ全カラムの値を一度出力し、ICSデータのUIDにあたるデータが関連づいているカラムを探し、「_sync_id」カラムの値とICSデータ(Googleカレンダー)で持っているEventのUIDとの、部分一致すると思われる箇所をまずは判断しました。
そうして、SQL文のLIKE句で検索するように selectionArgs と selection を組み立てることができました。

Calendar Provider へ ICSデータを insert する

Android側に未登録なイベントを登録処理する際にも、上述したEvent比較と同じ手法を使いながら一件ずつinsertをしていきます。
本アプリ内でそれに該当する箇所は ShowEventListActivityクラスの内部クラスQueryToProviderが持っている insertEvents() というメソッド、及びそこで呼び出されている別の内部クラスInsertAsync になります。

処理の前半の流れはほとんど前述のcompareToCalendarProvider()と変わりません。selectionArgs と selectionを組み立てて ContentResolverへquery結果のCursorオブジェクトを得るところまでは同じことをしています。

さて、比較結果を得るだけの時にはCursorオブジェクトが要素を持たないケースはスルーしました。ですがここでは、「Cursorオブジェクトが要素を持たないケース」とは「GoogleカレンダーのICSデータEventのUIDと同じデータがProvider側に無い時」のことなので、EventデータをCalendar Providerにinsertします。
insetの基本的な手順は次の通りです。

  • ContentValuesオブジェクトを用意して必要なデータをそれに詰め込んでいく。(保存先カラム名とデータをセットにしてputする)
  • 準備が整ったらContentResolverのinsert()メソッドにContentValuesオブジェクトを渡して処理してもらう。

ContentValuesオブジェクトにデータを詰めていく際に最初に注意する点は、前回の記事にも書いた日付時刻の扱いです。
Eventの開始時刻と終了時刻はICSデータ内では「20140524T090000」のような文字列で持っていますが、Calendar Provider側ではエポック秒で持つようなので、予め変換してContentValuesオブジェクトに詰めます。

    /* [事前に作っておくメソッド]
     * ICSデータの持つDTSTART等の日付文字列をParseしてDateTime型のオブジェクトを返すメソッド。
     * これの返すDateTime型オブジェクトからgetTime()でUNIXタイムスタンプを得る目的で使う。
     */
    private DateTime getDateTime(String strDateTime) {
        DateTime dt = null;
        try { //日付のみだった場合は時刻情報も付け足す
            if(strDateTime.length() == 8) dt = new DateTime(strDateTime + "T000000");
            else dt = new DateTime(strDateTime);
        } catch (ParseException e) { e.printStackTrace(); }
        return dt;
    }

    // DtStart と DtEnd は、ical4jのVEventから取った値を変換しないといけない
    ContentValues values = new ContentValues();
    Property prop = null;
    long startMillis = 0; // DtStartをミリ秒で受け取る変数
    long endMillis = 0;   // DtEnd  をミリ秒で受け取る変数
    if( (prop = event.getProperties().getProperty(Property.DTSTART)) != null ) {
        startMillis = getDateTime(prop.getValue()).getTime();
        if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            values.put(CalendarContract.Events.DTSTART, startMillis);
        } else {
            values.put("dtstart", startMillis);
        }
    }
    if( (prop = event.getProperties().getProperty(Property.DTEND)) != null ) {
        endMillis = getDateTime(prop.getValue()).getTime();
        if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            values.put(CalendarContract.Events.DTEND, endMillis);
        } else {
            values.put("dtend", endMillis);
        }
    }

日付時刻以外のデータは概ね何もいじらずにContentValuesオブジェクトへ詰めていきます。
ただし、ここでもAndroidのAPI Level 14未満の環境に関してはカラム名としてStringを直書き指定しなければならないので、最低限必要そうなカラムを下調べしておく必要がありました。
作者の判断は下記のコードの通りです。

    // DtStartとDtEnd以外は、ホイホイとputしていく。
    if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
        if( (prop = event.getProperties().getProperty(Property.SUMMARY)) != null )     values.put(CalendarContract.Events.TITLE, prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.LOCATION)) != null )    values.put(CalendarContract.Events.EVENT_LOCATION, prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.DESCRIPTION)) != null ) values.put(CalendarContract.Events.DESCRIPTION, prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.DURATION)) != null )    values.put(CalendarContract.Events.DURATION, prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.RDATE)) != null )       values.put(CalendarContract.Events.RDATE, prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.RRULE)) != null )       values.put(CalendarContract.Events.RRULE, prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.UID)) != null )         values.put(CalendarContract.Events.SYNC_DATA1, prop.getValue());
        values.put(CalendarContract.Events.EVENT_TIMEZONE, model.timezone.getID());

        values.put(CalendarContract.Events.CALENDAR_ID, localCalDtoList.get(localTargetCal_ChosenPosition).get_id());
    } else {
        if( (prop = event.getProperties().getProperty(Property.SUMMARY)) != null )     values.put("title", prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.LOCATION)) != null )    values.put("eventLocation", prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.DESCRIPTION)) != null ) values.put("description", prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.DURATION)) != null )    values.put("duration", prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.RDATE)) != null )       values.put("rdate", prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.RRULE)) != null )       values.put("rrule", prop.getValue());
        if( (prop = event.getProperties().getProperty(Property.UID)) != null )         values.put("_sync_id", "http://dummy.crappo.net/calendar/feeds/account/private/full/" + prop.getValue().split("@")[0]);
        values.put("eventStatus", 1); // 検証に使った環境では既存データに全て1がセットされたので、真似た。この設定値の根拠はそれだけ。API 14未満はAPI非公開だしどうしようもない。
        values.put("eventTimezone", model.timezone.getID());

        values.put("calendar_id", localCalDtoList.get(localTargetCal_ChosenPosition).get_id());
    }

    // AccountName と AccountTypeは本アプリ専用の値で決め打ち
    contentResolver.insert(asSyncAdapter(getEventProvider(), appName, packageName), values);
    values.clear();

上記処理中で使用している2つのメソッド asSyncAdapter() 及び getEventProvider() については前述のCalendar新規作成時と同様の事情で事前準備しておきます。

本アプリが作成したものに限定してカレンダーを選択削除する

最後に、Android内に自分のアプリが作成したカレンダーを削除できるようにする機能についてです。

まずは削除対象候補となるカレンダーリスト抽出をします。
本アプリではGoogleアカウントではなく独自のアカウントとしてAndroid内にローカルカレンダーを作成します。作る際には上述したcreateNewCalendar()メソッドの通り「アカウント名, アカウントタイプ」に「自アプリ名, 自アカウント名」を指定しているので、この条件でCalendar Providerから抽出を行います。
前述の各処理と同様にここでも、抽出条件を作成するときにはAndroid API level 14以上 or 未満で処理を分けます。

    // 対象箇所は内部クラスQueryToProviderのgetCalendars()メソッド
    CalendarDto calDto = new CalendarDto(); *// カレンダー情報の格納用自作クラス*
    String selection = null;

    if(android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH) {

        selection = "( " + Calendars.ACCOUNT_TYPE + " = ? )";
    } else {
        selection = "( " + "_sync_account_type" + " = ? )";
    }

    String[] selectionArgs = new String[] { packageName }; *// AccountTypeを決め打ちで指定する*

    Cursor c = getContentResolver().query(getCalProvider(), null, selection, selectionArgs, null);

    *// これで独自のアカウントタイプを持つカレンダーのリストを抽出できた。*

    if ( c != null && c.moveToFirst()) {
        do { *// Hitしたカレンダーの情報を、格納用オブジェクトCalendarDto詰めていく*
            *// ~~省略~~*
            *// カレンダー削除処理の際に必要なのは_ID(ContentProvider側で一意に持つ情報)*
            *// 少なくともこのデータは保持しておく。*
            calDto.set_id(c.getString(c.getColumnIndex(Calendars._ID)));
        } while (c.moveToNext());
    }
    c.close();

CotentResolverに渡す必要があるのは「_ID」のみなので、こうして得たカレンダーの_IDを使って、下記のようにしてカレンダーを削除します。

    String delTargetIdStr = calDto.get_id();
    ContentResolver cr = getContentResolver();

    Uri calUri = ContentUris.withAppendedId(asSyncAdapter(getCalProvider(), appName, packageName), Long.parseLong(delTargetIdStr));

    cr.delete(calUri, null, null);

上記処理中で使用している2つのメソッド asSyncAdapter() 及び getCalProvider() については前述の通りです。ContentUrisクラスのwithAppendedId()メソッドに「_ID」を渡してUriを生成し、ContentResolverのdelete()メソッドに渡して実行する、という書き方になります。

実装の説明は以上になります。誰かの何かの参考になれば幸いです。

Discussion