Android系统日历探索

基本原理

在 Android 里,日程数据其实不是直接由"日历 App"管理,而是由 日历内容提供器(CalendarProvider) 管理的。不同厂商的 ROM(比如小米、华为、OPPO)可能自研了日历 App,但通常仍然会复用 系统日历 Provider,否则很多第三方 App(微信、钉钉等)就没办法添加日程了。

想要对CalendarProvider进行读写需要访问日历权限:

复制代码
Manifest.permission.WRITE_CALENDAR
Manifest.permission.READ_CALENDAR

CalendarProvider 只有API >= 14 (Android 4.0) 才提供

CalendarProvider能提供查询和插入的数据种类都定义在CalendarContract中,比较重要的字段如下:

CalendarContract.Calendars._ID:日历账户唯一ID,想要写入日程最重要的一个参数就是账户ID,每一条日程记录都需要对应一个账户ID。一台手机上会有多个日历账户,也可能只有一个日历账户,例如Google日历支持登录多个账号,此时通过CalendarProvider查询账户能得到多个ID。而对于一些国产ROM例如OPPO即使你登录了OPPO账号,它也只有一个local account。

PS: 当权限不足时,也会出现ID为空的情况

CalendarContract.Calendars.VISIBLE:该日历是否在系统日历应用中对用户"可见"。仅是UI层面

CalendarContract.Events._ID:日程ID,可用于修改删除

权限获取

kotlin 复制代码
val permissions  = arrayOf(Manifest.permission.WRITE_CALENDAR, Manifest.permission.READ_CALENDAR)
if(!EasyPermissions.hasPermissions(this,*permissions)){
    ActivityCompat.requestPermissions(this, permissions, REQ_LOCATION_PERMISSION)
}

在实际使用时发现有两种权限弹窗:

在 Android 权限模型里:

  • READ_CALENDAR 和 WRITE_CALENDAR 是两个独立的危险权限
  • 但是它们都属于同一个 权限组(permission group) ------ android.permission-group.CALENDAR

这带来一个现象: 在 Android 6.0--9.0 (API 23--28) 系统只在 UI 上显示"日历"这个权限组,而不会细分到 READ 和 WRITE。

  • 用户一旦授予"日历"权限组,应用同时获得 READ + WRITE
  • 用户无法单独选择"只授予 WRITE"

在 Android 10 (API 29) 及以后 Google 修改了权限模型:同一权限组内的权限在 UI 上可能分开展示(例如 位置权限 会有"仅允许精确定位/仅允许大概定位")。

但日历组目前(截至 Android 14)依然是合并的,用户无法只勾 WRITE 而不给 READ。

既然Android权限模型中,不论是单独申请READ还是WRITE,实际都是一并给予权限,为什么还是会有机型出现图一的"仅允许创建"。并且当用户选择"仅允许创建"的情况下,确实读不到系统日程的内容

通过dumpsys package xxx | grep permission指令查询权限情况,能发现,即使是图一这种手机,无论用户选择"允许"还是"仅允许创建"得到的权限都是一样的。那只能说明厂商在CalendarProvider进行了二次开发

厂商为了提升"隐私权限可控性",在 系统 ContentProvider 框架层 增加了拦截逻辑:

  • 即使底层 PackageManager 认为你有 READ_CALENDAR
  • 系统在访问 CalendarProvider 的时候,会根据用户选择的权限模式做"二次判断"
  • 如果用户选择了"仅允许创建",系统会在 Provider 层拦截 query 操作,只放行 insert
  • 所以 表面上有 READ,但访问时被屏蔽。

检查手机是否支持CalendarProvider

kotlin 复制代码
fun hasCalendarProvider(context: Context): Boolean {
    val pm = context.packageManager

    // 2. 检查 Provider 是否存在
    val providers = listOf(
        ComponentName(
            "com.android.providers.calendar",
            "com.android.providers.calendar.CalendarProvider2"
        ),
        ComponentName(
            "com.android.providers.calendar",
            "com.android.providers.calendar.CalendarProvider"
        )
    )

    var providerExists = false
    for (component in providers) {
        try {
            pm.getProviderInfo(component, 0)
            providerExists = true
            break
        } catch (_: PackageManager.NameNotFoundException) {
            // ignore
        }
    }

    if (!providerExists) return false

    // 3. 尝试访问 CalendarContract.Calendars.CONTENT_URI
    return try {
        val uri: Uri = CalendarContract.Calendars.CONTENT_URI
        val cursor = context.contentResolver.query(
            uri,
            arrayOf(CalendarContract.Calendars._ID),
            null,
            null,
            null
        )
        cursor?.use {
            return it.count >= 0 // 能查询说明可用
        }
        false
    } catch (e: Exception) {
        false
    }
}

查询所有账户

kotlin 复制代码
// 查询所有可见账户
fun queryCalendars(context: Context): List<CalendarAccount> {
    val calendars = mutableListOf<CalendarAccount>()

    val projection = arrayOf(
        CalendarContract.Calendars._ID, // 账户ID
        CalendarContract.Calendars.ACCOUNT_NAME, // 该日历所属的 账号名。是 Android 账号管理(AccountManager)里的账号名,和 ACCOUNT_TYPE 一起标识一个账号。
        CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, // 日历名
        CalendarContract.Calendars.OWNER_ACCOUNT, // 该日历的"所有者账号"。通常用来标识谁是这个日历的创建者/管理者。
        CalendarContract.Calendars.VISIBLE, // 是否可见
        CalendarContract.Calendars.ACCOUNT_TYPE //账号类型
    )

    val cursor: Cursor? = try {
        context.contentResolver.query(
            CalendarContract.Calendars.CONTENT_URI,
            projection,
            "${CalendarContract.Calendars.VISIBLE} = 1",
            null,
            null
        )
    } catch (e: SecurityException) {
        e.printStackTrace()
        null
    }

    cursor?.use {
        val idIndex = it.getColumnIndex(CalendarContract.Calendars._ID)
        val accountIndex = it.getColumnIndex(CalendarContract.Calendars.ACCOUNT_NAME)
        val nameIndex = it.getColumnIndex(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)
        val ownerIndex = it.getColumnIndex(CalendarContract.Calendars.OWNER_ACCOUNT)
        val visibleIndex = it.getColumnIndex(CalendarContract.Calendars.VISIBLE)
        val accountTypeIndex = it.getColumnIndex(CalendarContract.Calendars.ACCOUNT_TYPE)

        while (it.moveToNext()) {
            val id = it.getLong(idIndex)
            val accountName = it.getString(accountIndex) ?: ""
            val displayName = it.getString(nameIndex) ?: ""
            val ownerAccount = it.getString(ownerIndex)
            val visible = it.getInt(visibleIndex) == 1
            val accountType = it.getString(accountTypeIndex) ?: ""

            calendars.add(
                CalendarAccount(
                    id = id,
                    accountName = accountName,
                    displayName = displayName,
                    ownerAccount = ownerAccount,
                    visible = visible,
                    accountType = accountType
                )
            )
        }
    }

    return calendars
}

向指定账户写入日程

kotlin 复制代码
fun addEventToCalendar(context: Context, id: Long = 0) {
    val startMillis: Long
    val endMillis: Long
    Calendar.getInstance().apply {
        set(2025, Calendar.AUGUST, 20, 9, 0) // 2025-08-20 09:00
        startMillis = timeInMillis
    }
    Calendar.getInstance().apply {
        set(2025, Calendar.AUGUST, 20, 10, 0) // 2025-08-20 10:00
        endMillis = timeInMillis
    }

    val values = ContentValues().apply {
        put(CalendarContract.Events.DTSTART, startMillis)
        put(CalendarContract.Events.DTEND, endMillis)
        put(CalendarContract.Events.TITLE, "会议")
        put(CalendarContract.Events.DESCRIPTION, "测试测试测试项目讨论会议")
        put(CalendarContract.Events.CALENDAR_ID, id)
        put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
    }

    val uri: Uri? = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)
    val eventId = uri?.lastPathSegment
}

可支持写入参数

参数兼容性

以下参数是 Calendar API 最基础的字段,从 API 1(很早)就有:因此无需考虑兼容性,均可写入

  • DTSTART / DTEND
  • TITLE
  • DESCRIPTION
  • EVENT_LOCATION
  • EVENT_TIMEZONE
  • ALL_DAY
  • HAS_ALARM
  • CALENDAR_ID

注意: 厂商定制影响大:部分国产 ROM(比如国内的无 Google 框架设备)会裁剪 CalendarProvider,插入后可能不会显示或会丢字段。

Google 日历 vs 系统日历:Google 日历应用会支持大多数字段,但某些国产系统日历可能忽略颜色、参与人、访问级别等信息。

测试建议 : 插入数据前用context.contentResolver.query(CalendarContract.Events.CONTENT_URI, null, null, null, null) 看看当前设备支持哪些列,避免写入无效字段。

读取指定账号日程

kotlin 复制代码
fun queryEventsByCalendarId(context: Context, calendarId: Long): List<CalendarEvent> {
    val events = mutableListOf<CalendarEvent>()

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

    val selection = "${CalendarContract.Events.CALENDAR_ID} = ?"
    val selectionArgs = arrayOf(calendarId.toString())

    val cursor: Cursor? = try {
        context.contentResolver.query(
            CalendarContract.Events.CONTENT_URI,
            projection,
            selection,
            selectionArgs,
            "${CalendarContract.Events.DTSTART} ASC" // 按开始时间升序
        )
    } catch (e: SecurityException) {
        e.printStackTrace()
        null
    }

    cursor?.use {
        val idIndex = it.getColumnIndex(CalendarContract.Events._ID)
        val titleIndex = it.getColumnIndex(CalendarContract.Events.TITLE)
        val descIndex = it.getColumnIndex(CalendarContract.Events.DESCRIPTION)
        val startIndex = it.getColumnIndex(CalendarContract.Events.DTSTART)
        val endIndex = it.getColumnIndex(CalendarContract.Events.DTEND)
        val allDayIndex = it.getColumnIndex(CalendarContract.Events.ALL_DAY)
        val tzIndex = it.getColumnIndex(CalendarContract.Events.EVENT_TIMEZONE)

        while (it.moveToNext()) {
            events.add(
                CalendarEvent(
                    id = it.getLong(idIndex),
                    title = it.getString(titleIndex),
                    description = it.getString(descIndex),
                    startTime = it.getLong(startIndex),
                    endTime = it.getLong(endIndex),
                    allDay = it.getInt(allDayIndex) != 0,
                    timezone = it.getString(tzIndex)
                )
            )
        }
    }

    return events
}

删除指定日程

kotlin 复制代码
fun deleteEventById(context: Context, eventId: Long): Boolean {
    val uri: Uri = CalendarContract.Events.CONTENT_URI
    val selection = "${CalendarContract.Events._ID} = ?"
    val selectionArgs = arrayOf(eventId.toString())

    return try {
        val rowsDeleted = context.contentResolver.delete(uri, selection, selectionArgs)
        rowsDeleted > 0
    } catch (e: SecurityException) {
        e.printStackTrace()
        false
    }
}
相关推荐
auxor14 分钟前
Android 开机动画音频播放优化方案
android
whysqwhw33 分钟前
安卓实现屏幕共享
android
深盾科技1 小时前
Kotlin Data Classes 快速上手
android·开发语言·kotlin
一条上岸小咸鱼1 小时前
Kotlin 基本数据类型(五):Array
android·前端·kotlin
whysqwhw1 小时前
Room&Paging
android
whysqwhw2 小时前
RecyclerView超长列表优化
android
whysqwhw2 小时前
RecyclerView卡顿
android
whysqwhw2 小时前
RecyclerView 与 ListView 在性能优化方面
android
檀越剑指大厂5 小时前
容器化 Android 开发效率:cpolar 内网穿透服务优化远程协作流程
android