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);
}
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_NAMECalendars.ACCOUNT_TYPECalendars.OWNER_ACCOUNT
这些字段受android.permission.READ_CALENDAR保护,但还有一个隐藏条件:只有系统应用或签名应用才能写入账户信息。
我记得有一次,产品经理要求App能自动创建日历账户。我查了半天文档,发现普通App根本做不到——这是系统级权限,需要signature级别。最后只能引导用户手动添加。
20.6 知识体系梳理
下面这张图,把日历权限的核心逻辑串起来了:
20.7 避坑指南
最后,我把这些年踩过的坑总结一下:
- 权限被撤销后,Cursor会失效。用户可能在设置里关掉权限,但你的Cursor还开着。我建议在
onResume()里重新检查权限状态。 - 不要在主线程查询大量事件。用
AsyncQueryHandler或者协程,否则容易ANR。 - 写入事件后,系统可能不会立即刷新。我遇到过写入成功但日历App里看不到的情况。可以尝试调用
ContentResolver.notifyChange()强制刷新。 - 模拟器上的日历数据是空的。测试时记得先手动添加一个日历账户,否则查不到任何数据。
嗯,日历权限这块,说白了就是「读要限范围,写要绑账户」。把这两个核心点抓住,大部分问题都能迎刃而解。