CalendarContractを使って、参加している予定だけ取得した

# はじめに
https://qiita.com/kft12/items/c8af20f244589708e974

こちらの通り、Androidアプリを作りました。
その際にCalendarContractを利用したのですが、かなり詰まることが多かったので備忘録がてら残しておきます。

# CalendarContractとは
Androidが提供している機能で、デバイス内のカレンダー情報を取得したり登録・削除したり出来ます。
今回は取得しかしていないので、登録・削除に関しては他の資料をご参照ください・・。

GoogleCalendarAPIと違って、前準備がほぼいらないのが嬉しいポイント。

# 情報
## 前準備
たった一つだけ必要な準備があります。
それはカレンダーの読み取り権限の取得です。

下記の通り、`AndroidManifest.xml`に権限がほしい旨を書いておきます。
```xml:AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

ちなみに、これだけだと良くある「許可しますか？」的なポップアップは出ません。
そこの処理は今回実装していないので、わからん！！！

## CalendarContractのデータモデル
CalendarContractで取得出来るものは、ざっくりこちらの5つです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2603116/10ffb23b-bfc8-4078-8249-f296c8697cb4.png)
参照：https://developer.android.com/identity/providers/calendar-provider?hl=ja

ちなみに、基本的に自分より上のテーブルが保持している項目をきっちり継承してくれています。
そのため、`Attendees`のデータは自分より上の`Events` `Calendars`のデータを取得出来ます。

### Calendars
カレンダー自体の情報が入っています。
メールアドレスや表示名、色などです。

### Events
https://developer.android.com/reference/android/provider/CalendarContract.Events

カレンダーの予定の大元の情報です。
少しややこしいのが、予定1つ1つの情報ではないという点。

説明が難しいですが・・。
繰り返し予定だとしても単発の予定だとしても、Eventsは1つしかないといった感じです。

### Instances
https://developer.android.com/reference/android/provider/CalendarContract.Instances

カレンダーの予定1つ1つの情報です。
予定を取得したい場合、こちらを取ってくるべき。

繰り返し予定であれば繰り返しの分だけデータがあります。

### Attendees
予定の参加者情報です。
その予定に対して「参加するか/不参加とするか」を保持しています。

繰り返しの予定のうち、1つだけ不参加とした場合どういうデータ構造になるんだろう・・？

### Reminders
使ってないのでわかりません。

# 実装
## 対象者の予定のうち、すべての予定を取得
`Instances`にクエリをかけて取得する。

```kotlin
// 取得対象とするカレンダーのメールアドレス。
private val TARGET_ADDRESS_WORK = arrayOf("work1@example.com", "work2@example.com")
private val TARGET_ADDRESS_PERSONAL = arrayOf("personal@example.com")

// 予定取得時に取得する項目を定義
private val INSTANCES_PROJECTION = arrayOf(
    Instances.TITLE,
    Instances.BEGIN,
    Instances.END,
    Instances.EVENT_LOCATION,
    Instances.EVENT_ID,
)
// あんまりよくわかってないけど、取得項目のindexを事前に定義しようって書いてありました
private val INSTANCES_IDX_TITLE = 0
private val INSTANCES_IDX_BEGIN = 1
private val INSTANCES_IDX_END = 2
private val INSTANCES_IDX_LOCATION = 3
private val INSTANCES_IDX_EVENT_ID = 4

fun searchCalendar(context: Context): MutableList<WidgetItem> {

    // Uriの組み立て
    // 対象がInstancesの場合、単純にCONTENT_URIを使うだけではなく開始日時と終了日時の定義が必要
    val uriBuilder: Uri.Builder = Instances.CONTENT_URI.buildUpon()
    ContentUris.appendId(uriBuilder, START_DATE_MILLI)
    ContentUris.appendId(uriBuilder, END_DATE_MILLI)
    val uri: Uri = uriBuilder.build()

    // 取得条件
    val where = arrayOf("(${Instances.CALENDAR_DISPLAY_NAME} IN (?, ?, ?)"
            ,"AND ${Instances.TITLE} <> \"不在\"" // 不在用のスケジュールか否かを判定する項目がないので、タイトルで判定している
            ,")").joinToString(" ")

    // 条件値
    val args = mutableListOf<String>()
    args.addAll(TARGET_ADDRESS_PERSONAL)
    if (IS_ALL) {
        args.addAll(TARGET_ADDRESS_WORK)
    }

    // カレンダーの予定を取得する
    val cur = context.contentResolver.query(uri, INSTANCES_PROJECTION, where, args.toTypedArray(), Instances.BEGIN)

    val instances: MutableList<Instance> = mutableListOf()
    while (cur?.moveToNext() == true) {
        val title = cur.getString(INSTANCES_IDX_TITLE)
        val begin = cur.getLong(INSTANCES_IDX_BEGIN)
        val end = cur.getLong(INSTANCES_IDX_END)
        val location = cur.getString(INSTANCES_IDX_LOCATION)
        val eventId = cur.getString(INSTANCES_IDX_EVENT_ID)

        // 取得した内容をお好みで利用する
    }
    cur?.close()

    return filteringCalendar(context, instances)
}
```

## 取得した予定のうち「参加しない」としている予定は省く
参加するか否かの情報は`Attendees`が持っているので、別途取得する。

```kotlin
private fun filteringCalendar(context: Context, instances:MutableList<Instance>): MutableList<WidgetItem> {

    // AttendeesはCONTENT_URIの加工不要
    var uri = Attendees.CONTENT_URI

    // 条件組み立て
    val sb:StringBuilder = StringBuilder()
    // 上で取得したInstancesの数分「?」を用意する
    val eventIds:MutableList<String> = mutableListOf()
    for (i in 0..instances.size-1) {
        if (i > 0) {
            sb.append(",")
        }
        sb.append("?")

        eventIds.add(instances[i].eventId)
    }

    // 取得条件
    val where = arrayOf("(${Attendees.ATTENDEE_EMAIL} IN (?, ?, ?)" // 複数の参加者が設定されている予定もあるので、こちらでもメールアドレスを絞り込む
            ,"AND ${Attendees.ATTENDEE_STATUS} = ${Attendees.ATTENDEE_STATUS_DECLINED}"
            ,"AND ${Attendees.EVENT_ID} IN (${sb})"
            ,")").joinToString(" ")

    // 条件値
    val args = mutableListOf<String>()
    args.addAll(TARGET_ADDRESS_PERSONAL)
    if (IS_ALL) {
        args.addAll(TARGET_ADDRESS_WORK)
    }
    args.addAll(eventIds)

    // 不参加になっている予定のみ取得する
    val cur = context.contentResolver.query(uri, ATTENDEES_PROJECTION, where, args.toTypedArray(), null)
    val declinedIdSet:HashSet<String> = hashSetOf()
    while (cur?.moveToNext() == true) {
        declinedIdSet.add(cur.getString(ATTENDEES_IDX_EVENT_ID))
    }

    // InstancesをWidgetItemに変換しつつ、不参加のものを除く
    val widgetItems: MutableList<WidgetItem> = mutableListOf()
    for (instance in instances) {
        if (declinedIdSet.contains(instance.eventId)) {
            continue
        }

        widgetItems.add(WidgetItem(
            day
            ,instance.title
            ,instance.timeStr
            ,instance.location
        ))
    }

    return widgetItems
}
```

# おわりに
なかなか日本語の資料が少なく、手間取ったけど楽しかったです。