Безысходность и отчаяние я испытывал много дней подряд, пытаясь "подключить" Google календарь к своему приложению. Так долго и так тяжело, как тогда, я не буксовал ни над одной фичей... Я сделал это! Прошло более двух месяцев, пока я и мои почти 200 активных пользователей не протестировали этот функционал в полной мере. Теперь я готов поделиться своим опытом, ибо в сети на русском языке (да и на английском тоже) я не нашел удовлетворяющее меня описание того, как работать с Google календарем через Content Provider.

Постановка задачи

Я работаю над приложением "Учет клиентов для самозанятых". Мне необходимо было реализовать в нем возможность планирования встреч с клиентами. Я сразу остановил свой выбор на Google календаре, т.к. в своей профессиональной деятельности активно им пользуюсь. Более того, мне была важна возможность общего доступа к календарю и его синхронизация с облачным сервисом Google. Супруга у меня тоже самозанятая и нам очень удобно планировать нашу семейную жизнь, поделившись друг с другом доступами для чтения к календарю каждого.

Самое простое решение, когда необходим Google календарь - воспользоваться Интентом, с помощью которого можно запускать стандартное приложение Календарь, чтобы добавлять новые события. Мне это решение не подходило по нескольким причинам. Во-первых, помимо добавления событий, мне так же нужно их читать и редактировать. Во-вторых, мое приложение работало со своей локальной Базой Данных, содержащей конфиденциальную информацию (самозанятые коллеги психологи меня поймут), которую не хотелось бы выносить за пределы приложения. И тогда необходимо решать, как локальные данные синхронизировать с данными Google календаря. Вызовом стандартного приложения Календаря не обойтись.

Приложение должно само добавлять, редактировать и читать данные из Google календаря. Синхронизировать его с облаком, если это необходимо. Для этого Google предлагает воспользоваться контент провайдером (Content Provider). Излагать подробно теорию не буду, ибо я не профи, а любитель. Прошу заранее прощение за возможные неточности в изложении и косяки в коде. По мере необходимости буду давать ссылки на статьи, из которых сам черпал информацию.

Решение задачи

Я так понимаю, что доступ к Google календарю в смартфоне похож на доступ к Базе Данных. Точнее, сам Календарь и все, что в нем есть по сути хранится в таблицах, как данные в БД. При помощи Контент провайдера (Content Provider) можно осуществлять запросы в БД Календаря: сохранять, изменять или удалять данные - события, календари, напоминания... Подробнее можете ознакомиться с работой Контент провайдера по ссылке: https://developer.android.com/guide/topics/providers/content-provider-basics

1. Получаем доступ к Календарю

Как во многом, что находится вне приложения, сначала необходимо получить к этому доступ. Получение доступа состоит из двух частей:

1.1. Указываем в манифесте необходимость доступа

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

Я на всякий случай запрашиваю доступ и на чтение, и на запись, хотя первое, возможно, делать и не обязательно.

1.2. Просим пользователя разрешить доступ

Все функции, отвечающие за интеграцию приложения с Google календарем, я упаковал в отдельный класс CalManager, однако доступ у пользователя запрашиваю из MainActivity, т.к. возвращаемый пользователем ответ (ActivityResultCallback), я так понимаю, принимать и обрабатывать возможно только в ней.

private val calendarPermission = registerForActivityResult(
  ActivityResultContracts.RequestMultiplePermissions()
) { map ->
        if (
          map[Manifest.permission.WRITE_CALENDAR] == true && 
          map[Manifest.permission.READ_CALENDAR] == true
        )
            initCalendar()
        else showAskWhyDialog()
    }

private fun initCalendar() {
        calManager = CalManager(this)
        if(!calManager.checkPermission())
            calendarPermission.launch(arrayOf(
              Manifest.permission.WRITE_CALENDAR, 
              Manifest.permission.READ_CALENDAR
            ))
    }

Для получения доступа я использую RequestMultiplePermissions контракт, который пришел на замену устаревшего onRequestPermissionsResult. Подробнее о нем можно прочесть здесь: https://developer.android.com/training/permissions/requesting

Если кратко, то сначала инициализирую переменную calendarPermission, с помощью которой регистрирую запрос разрешений к Календарю и определяю Callback, который выполнится, когда пользователь отреагирует на просьбу дать доступ. Если разрешения получены, то запускаю функцию initCalendar(), иначе спрашиваю у пользователя "В чем дело, неужели сложно разрешить мне элементарную вещь? :)" - showAskWhyDialog()

Мое приложение без доступа к Календарю не работает, поэтому в функции initCalendar() проверяю доступ и опять его запрашиваю. Функция calManager.checkPermission(), описанная в классе CalManager, выглядит следующим образом:

fun checkPermission(): Boolean {
        return ContextCompat.checkSelfPermission(
            context, Manifest.permission.WRITE_CALENDAR
        ) == PackageManager.PERMISSION_GRANTED
    }

Не спрашивайте меня, почему я запрашиваю разрешения на чтение и запись в Календаре, а проверяю только запись... :)

1.3. Настойчиво просим дать доступ к Календарю

Почему-то мой аппарат при попытке приложения запросить доступ повторно, если ранее пользователь отклонил запрос, игнорирует его, как-будто он поставил галочку "Больше не показывать", хотя ее нет в диалоговом окне. Поэтому, если пользователь сразу не дал доступ, то в диалоговом окне я доходчиво ему объясняю, в чем он не прав и направляю его в настройки смартфона, чтобы он дал доступ вручную.

private fun showAskWhyDialog() {
        val builder = AlertDialog.Builder(this)
        builder.setTitle(getString(R.string.cal_permission_deny_title))
            .setMessage(R.string.cal_permission_deny_message)
            .setCancelable(false)
            .setPositiveButton(getText(R.string.cal_permission_deny_yes)) { dialog, id ->
                // открываем настройки приложения, чтобы пользователь дал разрешение вручную
                val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                val uri = Uri.fromParts("package", this.packageName, null)
                intent.data = uri
                getPermissionManually.launch(intent)
            }
            .setNegativeButton(getText(R.string.cal_permission_deny_no)) { dialog, id ->
                finish()
            }
        val dlg = builder.create()
        dlg.show()
    }

А так выглядит переменная getPermissionManually, отвечающая за callBack:

private val getPermissionManually = registerForActivityResult(
  ActivityResultContracts.StartActivityForResult()
) {
        initCalendar()
    }

Бесконечный цикл - пока не дашь доступ, дальше не пройдешь! No pasaran! :)

2. Выбираем или создаем календарь, в котором будем сохранять события

Далее я буду описывать функции, содержащиеся в классе CalManager, отвечающие в моем приложении за работу Календаря. В первую очередь необходимо выбрать или создать календарь.

2.1. Получаем список доступных на смартфоне календарей

class ListCalendars {
    var id : Long = 0
    var name = ""
    var accountName = ""
    var accountType = ""
}

fun getCalendars(): ArrayList<ListCalendars> {
        val calList = ArrayList<ListCalendars>()
        if (checkPermission()) {
            val projection = arrayOf(
                Calendars._ID,
                Calendars.NAME,
                Calendars.ACCOUNT_NAME,
                Calendars.ACCOUNT_TYPE
            )
            val selection = "${Calendars.CALENDAR_ACCESS_LEVEL} = ${Calendars.CAL_ACCESS_OWNER}"
            val cursor: Cursor? = context.contentResolver.query(
                Calendars.CONTENT_URI,
                projection,
                selection,
                null,
                Calendars._ID + " ASC"
            )
            if (cursor != null) while (cursor.moveToNext()){
                val calendar = ListCalendars()
                calendar.id = cursor.getLong(0)
                calendar.name = cursor.getStringOrNull(1) ?: ""
                calendar.accountName = cursor.getString(2)
                calendar.accountType = cursor.getString(3)
                calList.add(calendar)
            }
            cursor?.close()
        }
        return calList
    }

В каждой функции, на всякий случай, я проверяю, есть ли доступ к календарю. Не уверен, что это необходимо, но возможен такой вариант, когда пользователь даст доступ, а потом его попросит обратно, тогда приложение "вылетит" с ошибкой. Случай маловероятный, но лучше перестраховаться.

Как видите, получение списка календарей один в один похоже на запрос к Базе Данных. В переменной projection указываем нужные нам имена полей таблицы со списком календарей. В selection - описываем условие, указав только те календари, в которых пользователь считается полноправным владельцем. Потом осуществляем запрос (contentResolver.query) и берем результат из переменной cursor, сохраняя для каждого календаря его имя, аккаунт, тип аккаунта и id. На выходе имеем список календарей calList.

2.2. Указываем календарь, в зависимости от размера списка

fun setCalendar(calList: ArrayList<ListCalendars>){
        // определяем календарь
        when (calList.size) {
            1 -> {
                setCalendarId(calList[0].id)
                accountType = calList[0].accountType
                accountName = calList[0].accountName
                setCalendarVisibilityAndSync()
                Toast.makeText(context, context.resources.getString(R.string.cal_set_lonely),
                    Toast.LENGTH_LONG).show()
            }
            0 -> {
                val newCalUri = createCalendar()
                if (newCalUri != null) {
                    setCalendarId(ContentUris.parseId(newCalUri))
                    accountName = "customer_accounting"
                    accountType = CalendarContract.ACCOUNT_TYPE_LOCAL
                    Toast.makeText(context, context.resources.getString(R.string.cal_create_success),
                        Toast.LENGTH_LONG).show()
                }
                else Toast.makeText(context, context.resources.getString(R.string.cal_create_error),
                    Toast.LENGTH_LONG).show()
            }
            else -> {

                var isLocalCalendarExist = false
                calList.forEach {
                    if (it.accountName == "customer_accounting") isLocalCalendarExist = true
                }
                if (!isLocalCalendarExist) createCalendar()

                chooseCalendar(calList)
            }
        }
    }

Если календарь в списке один, то его и выбираем. Если нет ни одного календаря, то создаем новый. Если календарей больше одного, то проверяем, создан ли локальный календарь "customer_accounting", создаем его, если нет и даем пользователю выбрать календарь. Назначение функции setCalendarVisibilityAndSync() я поясню далее.

Тут необходимо сказать, что в приложении можно создавать только локальный календарь. Его нельзя синхронизировать с облаком и просматривать события из него на разных устройствах. Подробнее о том, как работать с Google Календарем через свое приложение можно прочесть здесь: https://developer.android.com/guide/topics/providers/calendar-provider

2.3. Создаем календарь, если это необходимо

fun createCalendar(): Uri? {
        if (checkPermission()) {
            val values = ContentValues().apply {
                put(Calendars.ACCOUNT_NAME, "customer_accounting")
                put(Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
                put(Calendars.NAME, context.resources.getString(R.string.cal_local_name_calendar))
                put(Calendars.CALENDAR_DISPLAY_NAME, context.resources.getString(R.string.cal_local_name_calendar))
                put(Calendars.CALENDAR_COLOR, -0x10000)
                put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
                put(Calendars.OWNER_ACCOUNT, "customer_accounting")
                put(Calendars.CALENDAR_TIME_ZONE, TimeZone.getDefault().id)
                put(Calendars.SYNC_EVENTS, 1)
                put(Calendars.VISIBLE, 1)
            }

            val builder: Uri.Builder = Calendars.CONTENT_URI.buildUpon()
            builder.appendQueryParameter(Calendars.ACCOUNT_NAME, "customer_accounting")
            builder.appendQueryParameter(
                Calendars.ACCOUNT_TYPE,
                CalendarContract.ACCOUNT_TYPE_LOCAL
            )
            builder.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
            return context.contentResolver.insert(builder.build(), values)
        } else return null
    }

Не сразу я понял, что в поле CALENDAR_TIME_ZONE необходимо указывать не название Зоны в строке, а ее id числом. В остальном новый календарь добавляется, как новая строка в таблицу календарей. Единственное, на что стоит обратить внимание, - это переменная builder. Она, я так понимаю, отвечает за построение Uri - пути, по которому находится необходимое место для записи в таблицу календарей. При ее настройке я указываю дополнительный параметр CALLER_IS_SYNCADAPTER - true. Дело в том, что доступ к Календарю можно получить двумя способами: "как приложение" или "как адаптер синхронизации". У второго способа - возможностей больше и... (спойлер) без него у меня не получилось редактировать повторяющиеся события. Но об этом - далее. Подробнее про "адаптер синхронизации" - здесь: https://developer.android.com/guide/topics/providers/calendar-provider#sync-adapter

2.4. Даем пользователю возможность выбрать календарь

private fun chooseCalendar(calList: ArrayList<ListCalendars>) {

        // создаем массив названий календарей и заполняем его
        var calendarNames : Array<String> = emptyArray()
        var calIndex = 0
        calList.forEachIndexed { index, calendar ->
            val subtitle = if (calendar.accountType == "LOCAL")
                context.resources.getString(R.string.cal_local_message) else calendar.accountName
            val name = calendar.name.ifEmpty { context.resources.getString(R.string.cal_without_name) }
            val title = "$name\n($subtitle)"
            calendarNames += title
            if (calendar.id == calendarId) calIndex = index
        }
        val builder = AlertDialog.Builder(context)
        builder.setTitle(context.resources.getString(R.string.cal_choose_cal))
            .setCancelable(false)
            .setSingleChoiceItems(calendarNames, calIndex) { dialog, index ->
                calIndex = index
            }
            .setPositiveButton(context.resources.getText(R.string.OK)) { dialog, id ->
                setCalendarId(calList[calIndex].id)
                accountName = calList[calIndex].accountName
                accountType = calList[calIndex].accountType
                setCalendarVisibilityAndSync()
                Toast.makeText(context,
                    "${context.resources.getString(R.string.cal_chosen_cal)} ${calendarNames[calIndex]}",
                    Toast.LENGTH_LONG).show()
            }
        val dlg = builder.create()
        dlg.show()
    }

Для предоставления выбора я использую стандартное диалоговое окно AlertDialog. Перед его показом пользователю, создаю массив имен календарей calendarNames и указываю в переменной calIndex - индекс выбранного ранее календаря. Обратите внимание, что при выборе календаря помимо его id я еще в обязательном порядке сохраняю имя календаря, accountName и accountType. Эти поля необходимы для работы "адаптера синхронизации" (наберитесь терпения - об этом чуть дальше).

3. CRUD-операции с событиями Google календаря

Для новичков, типа меня поясню, что CRUD - это Create, Read, Update, Delete. Но в данном случае вместо Create идет Insert. С нее и начнем.

3.1. Добавление нового события в Календарь

suspend fun insertEvent(
        title: String,
        _start: Long,
        duration: Long,
        _rrule: String = "",
        until: String = ""): Long?  = withContext(Dispatchers.IO) {
        return@withContext if (checkPermission()) {

            val timeZone = TimeZone.getDefault().id
            val rrule = _rrule + until
            val start = if (duration != 0L) _start else getAllDayStart(_start)
            val end = if (duration != 0L) start + duration else start + 24 * 60 * 60 * 1000

            val event = ContentValues().apply {
                put(Events.CALENDAR_ID, calendarId)  // ID календаря
                put(Events.TITLE, title) // Название события
                put(Events.DESCRIPTION, context.resources.getString(R.string.cal_local_name_calendar)) // указание принадлежности приложению
                put(Events.EVENT_TIMEZONE, timeZone)
                put(Events.EVENT_LOCATION, "")
                put(Events.DTSTART, start) // время начала
                put(Events.STATUS, Events.STATUS_CONFIRMED)
                if (duration == 0L) put(Events.ALL_DAY, 1)
                if (rrule.isNotEmpty()) {
                    put(Events.RRULE, rrule) // повторяемость
                    put(Events.DURATION, "P${duration/1000}S") // продолжительность
                } else {
                    put(Events.DTEND, end) // время окончания
                }
            }

            val eventUri = asSyncAdapter(Events.CONTENT_URI)
            val uri = context.contentResolver.insert(eventUri, event)
            syncCalendar()
            uri?.lastPathSegment?.toLongOrNull()
        } else null
    }

fun getAllDayStart(_start: Long) : Long {
        val cal = Calendar.getInstance()
        cal.timeInMillis = _start
        val cal2 = Calendar.getInstance()
        cal2.clear()
        cal2.timeZone = TimeZone.getTimeZone("UTC")
        cal2.set(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH))
        return cal2.timeInMillis
    }

Все операции с Календарем лучше осуществлять в "фоновом" (асинхронном) режиме, чтобы интерфейс приложения не подтормаживал. Google рекомендует для этого использовать AsyncQueryHandler (подробнее см. https://developer.android.com/reference/android/ content/AsyncQueryHandler). Я не стал, уж очень мне показался он чересчур замысловатым. Запускаю функции работы с Календарем при помощи Корутин (https://developer.android.com/kotlin/coroutines) в параллельном потоке. Я привык так работать с Базой Данных.

Повторять то, что уже написано о добавлении событий в статье "Calendar provider overview" (https://developer.android.com/guide/topics/providers/calendar-provider) не буду. Расскажу лишь о тех "граблях", на которые наступал я сам, когда писал и отлаживал код и своих "фишках".

  1. timeZone - как и при добавлении календаря, так и при добавлении события - id типа Int, а не String, будьте внимательны!

  2. rrule, который отвечает за правила повторяемости события, в моем случае, удобнее было разбить на две части: непосредственно правило и указание, до каких пор работает повторяемость. Это удобно впоследствии, когда повторяющиеся события будут редактироваться.

  3. start и end. Мне показалось удобным для тех записей в календаре, которым пользователь не указывает длительность (duration), создавать события целого дня. Тогда необходимо изменять им начало и конец, указывая полночь заданного дня и полночь + 24 часа. Ну и поле Events.ALL_DAY устанавливать в 1.

  4. Events.DESCRIPTION. Это единственное поле в записи события в календаре, где можно, как я понял, сохранить некий текст, указывающий принадлежность события моему приложению. У меня это - "Учет клиентов" (название приложения).

  5. rrule.isNotEmpty(). Черным по белому написано: если указываете повторяемость события в rrule, то вместо end - duration! Но кто подробно читает мануал? Точно не я. Будьте внимательны!

Объяснение концовки кода про функции asSyncAdapter() и syncCalendar() пока опускаю. Продолжаю держать интригу. Ибо для меня эта "жара" стоила 2 недель пота и слез! Две недели, Карл!!!

3.2. Изменение существующего события

suspend fun updateEvent(
        eventId: Long,
        title: String,
        _start: Long,
        duration: Long,
        _rrule: String = "",
        until: String = ""): Boolean = withContext(Dispatchers.IO){
        return@withContext if(checkPermission()) {

            val rrule = _rrule + until
            val start = if (duration != 0L) _start else getAllDayStart(_start)
            val end = if (duration != 0L) start + duration else start + 24 * 60 * 60 * 1000

            val event = ContentValues().apply {
                if (title.isNotEmpty()) put(Events.TITLE, title) // Название события - Имя клиента?
                if (start != 0L) put(Events.DTSTART, start) // время начала
                put(Events.ALL_DAY, if (duration == 0L) 1 else 0)
                if (rrule.isNotEmpty()) {
                    put(Events.RRULE, rrule) // повторяемость
                    if (duration != 0L) put(Events.DURATION, "P${duration/1000}S") // продолжительность
                } else {
                    put(Events.DTEND, end) // время окончания
                }
            }
            val eventUri = Events.CONTENT_URI
            val row = context.contentResolver.update(
              eventUri,
              event, 
              "${Events._ID} = $eventId", 
              null
            )
            syncCalendar()
            row == 1
        } else false
    }

Не спрашивайте меня, почему в случае update в отличие от insert я не использую "адаптер синхронизации". Может быть, с ним хуже работало, а может, я его убрал по каким-то для меня самого не ведомым причинам. Не знаю. Мне до сих пор не очень понятно, как он работает, но без него никак... В моем случае - никак! Подробнее расскажу ниже. А пока, вроде бы, пояснять в коде больше нечего. Изменяем событие по единственному условию - "${Events._ID} = $eventId" посему должна измениться лишь одна строка таблицы, т.е. успешный исход изменений - row == 1 (true).

3.3. Удаление события из Календаря

suspend fun deleteEvent(eventId: Long): Boolean = withContext(
        Dispatchers.IO) {
        return@withContext if (checkPermission()) {
            //val eventUri = asSyncAdapter(Events.CONTENT_URI)
            val eventUri = Events.CONTENT_URI
            val row = context.contentResolver.delete(
              eventUri,
              "${Events._ID} = $eventId", 
              null
            )
            syncCalendar()
            row == 1
        } else false
    }

Здесь также, как и в случае update, я не использую "адаптер синхронизации". Знатоки, подскажите в комментариях, как поступать правильно? Где его использовать, а где - нет, я, видимо, определял опытным путем. Про функцию syncCalendar() я напишу ниже.

3.4. Чтение событий Календаря

class ListEvents {
    var eventId : Long = 0
    var newEventId : Long = 0
    var title = ""
    var start : Long = 0
    var begin : Long = 0
    var end : Long = 0
    var duration : Long = 0
    var rrule = ""
}

suspend fun readEventsListOfDay(
        day: LocalDate): ArrayList<ListEvents> = withContext(
        Dispatchers.IO) {
        val dataList = ArrayList<ListEvents>()
        if (checkPermission()) {

            val calDate = Calendar.getInstance()
            calDate.timeZone = TimeZone.getDefault()
            calDate.set(day.year, day.monthValue - 1, day.dayOfMonth, 0, 0, 0)
            val start = calDate.timeInMillis
            calDate.add(Calendar.HOUR, 24)
            val end = calDate.timeInMillis

            val titleCol = CalendarContract.Instances.TITLE
            val startCol = CalendarContract.Instances.DTSTART
            val endCol = CalendarContract.Instances.END
            val idCol = CalendarContract.Instances.EVENT_ID
            val beginCol = CalendarContract.Instances.BEGIN
            val rruleCol = CalendarContract.Instances.RRULE

            val projection = arrayOf(titleCol, startCol, endCol, idCol, beginCol, rruleCol)
            val selection = "${Events.DELETED} != 1 " + // исключаем удаленные события
                    "AND ${Events.DESCRIPTION} = '${context.resources.getString(R.string.cal_local_name_calendar)}' " + // выбираем только те события, которые созданы приложением
                    "AND ${Events.CALENDAR_ID} = $calendarId " +
                    "AND $beginCol > $start "
            val order = "$beginCol ASC"

            val eventsUriBuilder = CalendarContract.Instances.CONTENT_URI
                .buildUpon()
            ContentUris.appendId(eventsUriBuilder, start)
            ContentUris.appendId(eventsUriBuilder, end)
            val eventsUri = eventsUriBuilder.build()

            val cursor = context.contentResolver.query(
                eventsUri,
                projection,
                selection,
                null,
                order
            )

            if (cursor != null) while (cursor.moveToNext()) {
                val item = ListEvents()
                item.eventId = cursor.getLongOrNull(cursor.getColumnIndex(idCol)) ?: 0
                item.title = cursor.getStringOrNull(cursor.getColumnIndex(titleCol)).orEmpty()
                item.begin = cursor.getLongOrNull(cursor.getColumnIndex(beginCol)) ?: 0
                item.start = cursor.getLongOrNull(cursor.getColumnIndex(startCol)) ?: 0
                item.end = cursor.getLongOrNull(cursor.getColumnIndex(endCol)) ?: 0
                item.rrule = cursor.getStringOrNull(cursor.getColumnIndex(rruleCol)).orEmpty()

                dataList.add(item)
            }
            cursor?.close()
        }
        return@withContext dataList
    }

Здесь уже плавно перехожу к описанию самого сложного для моего понимания - к работе с повторяющимися событиями (reccurent events). С ними я намаялся больше всего! Особенно в свете того, что в моем приложении мне необходимо было синхронизировать работу внутренней Базы Данных и Календаря.

Все события, как одиночные, так и повторяющиеся, записываются в таблицу Events. Для указания повторяемости события, как вы уже поняли, используются поля rrule и duration. А для того, чтобы прочитать события в некотором интервале времени, с учетом их повторяемости, делается запрос в таблицу Instances. В переменной eventsUriBuilder как раз указывается интервал времени (от start до end), из которого выбираются все входящие в него события.

Обратите внимание, что в таблице Instances у событий несколько иные поля. BEGIN и END - начало и конец отдельного события из серии повторяющихся событий или начало и конец одиночного события. EVENT_ID - id исходного события, которое задает серию повторяющихся событий или id одиночного события. DTSTART - начало первого события серии или начало одиночного события. В случае одиночного события DTSTART = BEGIN.

В остальном, как видите, чтение событий из Календаря ничем не отличается от запроса из Базы Данных. В projection даем массив необходимых нам полей. В selection - формулируем условия. Подробнее можете прочесть здесь: https://developer.android.com/guide/topics/ providers/calendar-provider#instances

4. Повторяющиеся события в Календаре

Начинается "моя боль". Добавлять повторяющиеся событие не сложнее, чем одиночные. Как читать данные из таблицы Instances, где отображаются события из серии повторяющихся, входящих в заданный интервал, разобрался довольно быстро. Но редактирование и удаление - боль, боль, боль...

Сначала я пытался указывать исключения в поле EXRULE или EXDATE - ничего хорошего из этого не получалось. Я так и не понял, для чего оно нужно, если не работает! Потом для того, чтобы удалить одно событие из серии повторяющихся или изменить его, я добавлял новое событие в таблицу исключений - эпик фэйл! Работало это крайне плохо. События редактировались или удалялись, но потом появлялись вновь, как будто в смартфоне "кто-то" проводит ревизию и все возвращает на свои места. При этом, если добавить новое повторяющееся событие, подождать приличное время и только потом его редактировать или удалять, то все работает нормально.

После двух недель мытарств и хождения по мукам (гугля все, что удавалось найти в интернете), я понял, что дело в синхронизации. Нельзя редактировать или удалять одно событие из серии повторяющихся, пока они не будут синхронизированы с облаком. Посему я задался этим вопросом и понял, что необходимо подключать приложение к Календарю, "как адаптер синхронизации" и синхронизировать календарь вручную сразу после добавления нового события.

4.1. Адаптер синхронизации и синхронизация Календаря

private fun setCalendarVisibilityAndSync() {
        val values = ContentValues()
        values.put(Calendars.SYNC_EVENTS, 1)
        values.put(Calendars.VISIBLE, 1)
        val uri = asSyncAdapter(ContentUris.withAppendedId(Calendars.CONTENT_URI, calendarId!!))
        context.contentResolver.update(uri, values, null, null)
    }

    private fun asSyncAdapter(uri: Uri): Uri {
        return uri.buildUpon()
            .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
            .appendQueryParameter(Calendars.ACCOUNT_NAME, accountName)
            .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build()
    }

    private fun syncCalendar() {
        val account = Account(accountName, accountType)
        val extras = Bundle()
        extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
        val authority = Calendars.CONTENT_URI.authority
        ContentResolver.requestSync(account, authority, extras)
    }

Функцию setCalendarVisibilityAndSync() в обязательном порядке запускаю для того Календаря, в который собираюсь сохранять сообщения. Она "включает" Календарю поля SYNC_EVENTS и VISIBLE.

Функция asSyncAdapter() добавляет к Uri параметры ACCOUNT_NAME, ACCOUNT_TYPE и указывает CALLER_IS_SYNCADAPTER - true.

Функция syncCalendar() запускает синхронизацию Календаря. Ее я запускаю каждый раз, сразу после внесения в календарь изменений. Тогда и только тогда не возникает ошибок при редактировании и удалении повторяющихся событий.

4.2. Редактирование или удаление одного из серии повторяющихся событий

		suspend fun deleteOneRecurrentEvent(eventId: Long, begin: Long): Long? = withContext(
        Dispatchers.IO) {
        return@withContext updateRecurrentEvent(eventId, begin)
    }

    private suspend fun updateRecurrentEvent(
        eventId: Long,
        begin: Long,
        newDate: Long = 0L): Long? = withContext(Dispatchers.IO) {
        return@withContext if (checkPermission()) {

            val event = ContentValues().apply {
                put(Events.ORIGINAL_INSTANCE_TIME, begin)
                if (newDate == 0L) put(Events.STATUS, Events.STATUS_CANCELED)
                else put(Events.DTSTART, newDate)
            }

            val eventUri = ContentUris.withAppendedId(CONTENT_EXCEPTION_URI, eventId)
            //val eventUri = asSyncAdapter(ContentUris.withAppendedId(CONTENT_EXCEPTION_URI, eventId))
            val uri = context.contentResolver.insert(eventUri, event)
            syncCalendar()
            uri?.lastPathSegment?.toLongOrNull()
        } else null
    }

Для того, чтобы изменить одно событие из серии повторяющихся, необходимо в таблицу исключений (CONTENT_EXCEPTION_URI) добавить новое событие. В нем обязательно необходимо указать поле ORIGINAL_INSTANCE_TIME - начало того события серии, которое хотим изменить. Если я хочу не изменить, а удалить его, то вместо параметра newDate указываю - ноль. Тогда добавляемому событию в таблице исключений, указываю статус - STATUS_CANCELED.

Опытным путем обнаружено, что добавлять новое событие в таблицу исключений лучше не используя "адаптер синхронизации", но в обязательном порядке сразу после добавления необходимо синхронизировать календарь - syncCalendar().

4.3. Удаление всех последующих повторяющихся событий серии

suspend fun deleteAllRecurrentEvents(
        eventId: Long,
        begin: Long,
        start: Long,
        duration: Long,
        rrule: String): Boolean = withContext(Dispatchers.IO) {
        return@withContext if (checkPermission()) {

            val until = fHelper.dateTimeFormatter(
              context.resources, 
              begin - 23*60*60*1000, 
              Const.RFC5545
            )
            updateEvent(eventId, "", start, duration, rrule, until)
        } else false
    }

В моем приложении нет возможности изменять все последующий события серии, только удалять. По сути, удаление некоторого события серии заключается в изменении исходного события (updateEvent). Я добавляю в правило повторения ограничение его действия через until. Функция dateTimeFormatter форматирует дату события в форме RFC5545 - YYYYMMDDTHHMMSSZ. Подробнее здесь: https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.2

Итоги

Очень может быть, код у меня получился не идеальный. Однозначно, я не до конца понимаю, как оно работает. Но практика показывает, что все с ним в порядке. Буду рад прочесть ваши комментарии. И надеюсь, этот гайд будет кому-нибудь полезен. Я ничего подобного в сети не нашел.

Комментарии (4)


  1. quaer
    09.05.2022 13:18
    +1

    Прошло более двух месяцев, пока я и мои почти 200 активных пользователей

    что за приложение, если не секрет, если оно окупает при 200 пользователей затраты в 2 месяца работы программиста только на подключение календаря? И зачем это в приложении если он есть отдельно в системе?


    1. Andrey_Ananiev Автор
      09.05.2022 15:25

      Моя работа бесценна :) поэтому не оплачивается. Ссылка на приложение: https://play.google.com/store/apps/details?id=ru.keytomyself.customeraccounting Календарь в системе может использоваться для планирования встреч с клиентами. А календарь в приложении позволяет расписание встреч интегрировать с заметками о консультациях, финансовой аналитикой и прочим функционалом, связанным со взаимодействием с клиентами.


  1. qoj
    09.05.2022 15:55
    +1

    Не уверен, что это необходимо, но возможен такой вариант, когда пользователь даст доступ, а потом его попросит обратно, тогда приложение "вылетит" с ошибкой.

    При отзыве пермишена система останавливает приложение, поэтому можно не проверять каждый раз.


    1. Andrey_Ananiev Автор
      09.05.2022 21:37

      Спасибо, проверю.