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