Безысходность и отчаяние я испытывал много дней подряд, пытаясь "подключить" 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) не буду. Расскажу лишь о тех "граблях", на которые наступал я сам, когда писал и отлаживал код и своих "фишках".
timeZone - как и при добавлении календаря, так и при добавлении события - id типа Int, а не String, будьте внимательны!
rrule, который отвечает за правила повторяемости события, в моем случае, удобнее было разбить на две части: непосредственно правило и указание, до каких пор работает повторяемость. Это удобно впоследствии, когда повторяющиеся события будут редактироваться.
start и end. Мне показалось удобным для тех записей в календаре, которым пользователь не указывает длительность (duration), создавать события целого дня. Тогда необходимо изменять им начало и конец, указывая полночь заданного дня и полночь + 24 часа. Ну и поле Events.ALL_DAY устанавливать в 1.
Events.DESCRIPTION. Это единственное поле в записи события в календаре, где можно, как я понял, сохранить некий текст, указывающий принадлежность события моему приложению. У меня это - "Учет клиентов" (название приложения).
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
Итоги
Очень может быть, код у меня получился не идеальный. Однозначно, я не до конца понимаю, как оно работает. Но практика показывает, что все с ним в порядке. Буду рад прочесть ваши комментарии. И надеюсь, этот гайд будет кому-нибудь полезен. Я ничего подобного в сети не нашел.
