20、权限与日历:READ_CALENDAR、WRITE_CALENDAR、日历事件权限

日历权限,说实话,在Android权限体系里属于「存在感不强但坑不少」的类型。我早年做一款会议管理App时,就因为它栽过跟头——用户明明授权了,却死活读不到事件。后来才发现,问题出在权限声明和账户同步机制上。

今天咱们就把日历权限这块彻底捋清楚。

20.1 日历权限的「三件套」

Android把日历相关的权限分成了三个维度:

权限名称 保护级别 作用范围
READ_CALENDAR dangerous 读取日历事件、参会人、提醒等
WRITE_CALENDAR dangerous 创建、修改、删除日历事件
日历事件权限(隐式) signature|privileged 访问日历账户、同步配置

前两个是运行时权限,需要弹窗申请。第三个比较特殊——它藏在系统API里,普通App拿不到。我遇到过有团队想通过CalendarContract.Calendars直接操作账户,结果在非系统App上直接崩了。

20.2 权限声明与运行时申请

AndroidManifest.xml里声明:

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

运行时申请代码:

if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR)
        != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(this,
            new String[]{Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR},
            CALENDAR_PERMISSION_REQUEST_CODE);
}
我的习惯:把READ和WRITE放在一次请求里。用户授权读取时,大概率也会同意写入。分两次弹窗反而容易引起反感。

20.3 读取日历事件——别踩「时间范围」的坑

读取日历事件,核心是查询CalendarContract.Events表。但有个细节很多人忽略:查询必须指定时间范围

val projection = arrayOf(
    Events._ID,
    Events.TITLE,
    Events.DTSTART,
    Events.DTEND,
    Events.DESCRIPTION
)

val selection = "((${Events.DTSTART} >= ?) AND (${Events.DTSTART} <= ?))"
val selectionArgs = arrayOf(
    startTime.toString(),
    endTime.toString()
)

val cursor = contentResolver.query(
    Events.CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    "${Events.DTSTART} ASC"
)

为什么必须加时间范围?说白了,日历事件数据量可能非常大。不加限制的话,系统会返回所有历史事件——我见过一个用户手机里有3万多个事件,直接导致UI线程卡死。

我曾经踩过的坑:Events.DTSTART做筛选时,忘了考虑时区问题。用户手机设置的是UTC+8,我传的时间戳却是UTC+0。结果查出来的事件全部偏移了8小时。后来我统一用TimeZone.getDefault()做转换,才彻底解决。

20.4 写入日历事件——别忘了「账户」

写入事件比读取复杂。关键点在于:每个事件必须关联一个日历账户

val values = ContentValues().apply {
    put(Events.DTSTART, startMillis)
    put(Events.DTEND, endMillis)
    put(Events.TITLE, "会议:Android权限审查")
    put(Events.DESCRIPTION, "讨论READ_CALENDAR实现方案")
    put(Events.CALENDAR_ID, calendarId)  // 必须指定
    put(Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
}

val uri = contentResolver.insert(Events.CONTENT_URI, values)

这个calendarId从哪里来?你得先查询用户手机上的日历账户:

val calCursor = contentResolver.query(
    Calendars.CONTENT_URI,
    arrayOf(Calendars._ID, Calendars.NAME),
    null, null, null
)

if (calCursor != null) {
    while (calCursor.moveToNext()) {
        val id = calCursor.getLong(0)
        val name = calCursor.getString(1)
        // 选择你需要的账户
    }
}

你想想看,如果用户手机上没有任何日历账户(比如新手机还没登录Google账号),那写入操作会直接失败。我一般会在写入前检查账户列表,如果没有就提示用户先添加账户。

20.5 日历事件权限——系统级API的「隐形门槛」

前面提到,CalendarContract.Calendars表里有些字段需要额外权限。比如:

  • Calendars.ACCOUNT_NAME
  • Calendars.ACCOUNT_TYPE
  • Calendars.OWNER_ACCOUNT

这些字段受android.permission.READ_CALENDAR保护,但还有一个隐藏条件:只有系统应用或签名应用才能写入账户信息

我记得有一次,产品经理要求App能自动创建日历账户。我查了半天文档,发现普通App根本做不到——这是系统级权限,需要signature级别。最后只能引导用户手动添加。

20.6 知识体系梳理

下面这张图,把日历权限的核心逻辑串起来了:

日历权限知识体系 READ_CALENDAR 读取事件、参会人、提醒 WRITE_CALENDAR 创建、修改、删除事件 日历事件权限 系统级·账户操作 查询Events表 指定时间范围 注意时区转换 插入/更新Events表 必须指定CALENDAR_ID 先查询可用账户 操作Calendars表 仅系统App可写入 普通App只能读取 常见陷阱 ① 不指定时间范围 → 数据量爆炸 ② 不检查账户 → 写入失败 ③ 忽略时区 → 事件偏移 ④ 试图创建账户 → 权限不足

20.7 避坑指南

最后,我把这些年踩过的坑总结一下:

  • 权限被撤销后,Cursor会失效。用户可能在设置里关掉权限,但你的Cursor还开着。我建议在onResume()里重新检查权限状态。
  • 不要在主线程查询大量事件。AsyncQueryHandler或者协程,否则容易ANR。
  • 写入事件后,系统可能不会立即刷新。我遇到过写入成功但日历App里看不到的情况。可以尝试调用ContentResolver.notifyChange()强制刷新。
  • 模拟器上的日历数据是空的。测试时记得先手动添加一个日历账户,否则查不到任何数据。

嗯,日历权限这块,说白了就是「读要限范围,写要绑账户」。把这两个核心点抓住,大部分问题都能迎刃而解。


公众号:蓝海资料掘金营,微信deep3321