Android Studio. Kotlin. Подключение Google календаря через Content Provider

от автора

Безысходность и отчаяние я испытывал много дней подряд, пытаясь «подключить» 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

Итоги

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


ссылка на оригинал статьи https://habr.com/ru/post/664876/