-
Notifications
You must be signed in to change notification settings - Fork 8
Notifications
本文基于 v1.0.54。
Sleepy 提供两类课程提醒:每日摘要和课前提醒。课前提醒可以再挂两种形态——系统通知栏横幅,以及小米/OPPO 上的流体云实时卡片。设置入口在「我的」页的「提醒」(ReminderScreen)。
「开启提醒」控制整个提醒系统。关掉它,所有已排的闹钟当场取消,后续也不排。Android 13 及以上,打开主开关会先向系统申请 POST_NOTIFICATIONS 权限:
- 用户已授权 → 直接开启,立即重排全部闹钟。
- 用户拒绝 → 开关弹回关闭状态,
reminder_master写回false。拒绝不残留任何半开状态。 - 权限弹窗可以反复触发。点一次开关弹一次窗,拒绝后再点还会再弹,没有"只问一次"的限制。
Android 12 及以下没有这个运行时权限,开关直接生效。
子开关默认开,提醒时间默认 07:00。时间用 24 小时制表盘选,确认后立即重排。排闹钟时把下一次触发定在今天的目标时刻,已过就顺延到明天,之后 INTERVAL_DAY 重复。即使配置存储损坏(比如读到 07:60 或空值),调度时会钳制回合法范围:小时收进 0–23,分钟收进 0–59,解析失败回落 7:00,不会崩。
设置页有两行示例文案预览,提醒发出前的实际内容以摘要通知为准。
子开关默认关。打开后可以填"提前多少分钟",默认 10,自由输入,只收数字,上限 999,下限钳到 1。输入做了 500ms debounce:停手半秒后才持久化并重排,避免每敲一个键就触发一次全量取消加重排(那次重排要查库、取消、再排所有闹钟)。
课前提醒的子开关,默认开。控制是否在系统通知栏弹普通课前通知。挂在 sleepy_before_class 渠道上,新装机后该渠道默认有提示音和横幅,App 不做额外的声音配置,要调去系统设置里调。
课前提醒的子开关,默认关。打开后多出一项「胶囊主显示内容」,三选一:课程名称 / 上课时间 / 上课地点,默认地点。是否真的渲染成流体云由手机系统和厂商服务决定,App 只负责把通知按规范发出去。
关掉主开关只做两件事:reminder_master 置 false,取消全部闹钟。每日提醒、课前提醒、横幅、流体云这四个子开关的存储值原样保留。重新打开主开关,所有子配置原样恢复。调度器和各个 Receiver 内部都会再查一遍主开关,所以子开关留着不会有任何副作用。
CourseNotificationScheduler.createChannels() 创建三个渠道,Android 8.0 以下跳过:
| 渠道 ID | 名称 | importance | 用途 |
|---|---|---|---|
sleepy_daily |
每日课程提醒 | DEFAULT | 每日摘要通知 |
sleepy_before_class |
课前提醒 | HIGH | 课前横幅通知 |
sleepy_fluid_v2 |
流体云 / 超级岛 | HIGH | 流体云实时卡片、前台服务通知 |
关掉某个渠道的系统级开关,对应形态就静默,App 侧不感知。
提醒靠 AlarmManager 驱动,一共三层:
| 闹钟 | 触发时间 | requestCode | 干什么 |
|---|---|---|---|
| 每日摘要 | 用户设定时间,INTERVAL_DAY 重复 |
1 | 发今日课程摘要 |
| 午夜排程器 | 每天 00:05,INTERVAL_DAY 重复 |
2 | 给当天每节课排独立闹钟 |
| 单节课课前 | 该节课开始前 N 分钟 | 100 + courseId | 发课前通知或拉起流体云 |
单节课闹钟的 requestCode 用 100 + courseId,courseId 是数据库主键,稳定唯一。取消时遍历库里现存课程逐个 cancel,PendingIntent 匹配只看 requestCode 和目标组件,extras 不参与,所以重复排同一节课会覆盖旧闹钟而不产生堆积。
还有两个通知 ID 常量:每日摘要固定 1001,课前通知固定 2000。通知 ID 不随课程变,同一天多节课的普通横幅互相覆盖,后到的顶掉先到的。
午夜排程器除了 00:05 的每日闹钟,当场还立刻排一次今天的课——覆盖"App 在午夜后才被打开"的场景,不等第二天凌晨。
BootReceiver 在 Manifest 里 exported="true"(收系统广播必须);通知 Receiver 里 BeforeClassNotifyReceiver 也是 exported="true",只有 DailyNotifyReceiver 与 BeforeClassScheduleReceiver 不导出。开机或 App 更新后重排,前提是主开关开着;关着就什么都不做,等用户自己打开。
Manifest 声明了 SCHEDULE_EXACT_ALARM。系统授予精确闹钟权限时,每类闹钟走精确路径:setRepeating / setExactAndAllowWhileIdle。未授权时自动降级:setInexactRepeating / set。降级后通知仍会到,只是可能晚几分钟,取决于系统批处理。
删除课表走外键 CASCADE,课程行跟着表一起没了。scheduleAll 内部的 cancelAll 靠查库枚举"现存课程"来取消课前闹钟——课程已删,枚举不到,对应的当天闹钟就成了孤儿,时间一到照样响。ScheduleRepository.deleteTable 因此在删除前先抓下该表全部课程 id,删完立刻调 cancelCourseAlarms(孤儿id) 显式取消。直接删课不走这条路,onDataChanged 里的 scheduleAll 能覆盖到。
scheduleTodayBeforeClassAlarms() 排当天课前闹钟时按这套规则筛选:
- 只排当前绑定的课表(
WidgetTableResolver.resolveCurrentTable()),其他表再满也不排。 - 今天不在学期范围内(开学前/结课后)直接返回,一节都不排。周数不做钳制,防止学期外误匹配第 1 周的课。
- 课程开始时间取自定义时间(
ownTime且startTime非空)或作息表节点时间;两者都拿不到、或小时分钟越界,跳过这一节。 - 通知时刻已过(
epoch <= now)跳过,不给已经开始的课补发。 - 只排今天星期几命中的课,且该课在当前教学周内(
inWeek)。 - 自定义时间的课按
startTime排,按节次走的课查作息表timeJson里对应节次的开始时间。
每日摘要和流体云的窗口判断用同一套口径。
DailyNotifyReceiver 发出,通知 ID 1001,BigTextStyle 承载长文案。今天有课:标题「今日 N 号,您有 X 节课」,正文是第一节的时间地点,附无课文案「好好休息,享受一天!」。没绑定课表或学期外,按无课处理。点通知进 App,看完自动消失。
发通知前做两次权限检查:Receiver 入口一次,真正 notify 前再内联查一次——中间隔着 IO 协程,用户可能在窗口期内撤权。
BeforeClassNotifyReceiver 发出。横幅开、流体云关(或系统低于 Android 8.0)时走这条:渠道 sleepy_before_class,高优先级,文案「下节课 X 于 HH:MM 在 Y 上课」,有老师就追加老师名。教室空白时显示「未知教室」占位。
横幅和流体云全关时,Receiver 直接 return,一条通知都不发。闹钟本身已经响过了,不会重排——下一节有课的时段再排新的。
FluidCloudService 是前台服务,Manifest 标注 specialUse 类型,不导出。Android 8.0 以上,流体云开关打开时,课前通知不走普通路径,改启这个服务:
- 通知用
ProgressStyle画连续倒计时进度条,不分段。进度按(当前时间-提醒时刻)/(上课时刻-提醒时刻)算,0 到 100。 - Handler 每 15 秒重新 post 一次通知推进度条,不靠系统自动更新。
- 到上课时刻,服务移除通知并自停。
- 胶囊收起态主字段(
setShortCriticalText)取用户选的主字段,截到 7 个字符:课程名、上课时间或地点。 - 通知颜色跟随 App 当前主题色,包括深浅色模式和 Material You 动态取色,用桌面组件同一套 scheme 解析。
- 通知静音(
setSilent),常驻,点开进 App。正文是「时间 · 地点 · 老师」,老师为空就省略。 - 服务收到已过期的启动请求时,仍先
startForeground发一个占位通知再撤掉——Android 12 起,startForegroundService之后不调startForeground会直接抛异常。服务不粘滞(START_NOT_STICKY)。 - Android 8.0 以下没有前台服务通知规格,退回
NotificationManagerCompat.notify路径,发普通通知并做权限检查;权限被拒时静默跳过。 - 每次冷启动,
SleepyApp.onCreate先撤掉上一次残留的课前通知(ID 2000),避免进程被杀后流体云卡片挂在通知栏。
| 时机 | 动作 |
|---|---|
开机 / App 更新(BootReceiver 收到 BOOT_COMPLETED / MY_PACKAGE_REPLACED) |
主开关开着就 scheduleAll() 全量重排 |
| 提醒页任何开关或配置变更 |
scheduleAll() 全量重排 |
| 主开关关闭 | cancelAll() |
| 课程增删改、课表删除、切默认表 |
onDataChanged 触发 scheduleAll();删表另加孤儿闹钟清理。调度失败或提醒未开都不影响写操作本身,异常被吞掉 |
| 提前分钟输入 | 停止输入 500ms 后重排 |
| 每天 00:05 |
BeforeClassScheduleReceiver 排当天全部课前闹钟 |
scheduleAll 的顺序固定:建渠道 → 全量取消 → 按开关重排。先取消后重排在同一个协程里完成,不留取消和重排交错的窗口。
课前闹钟是"提前 N 分钟那一秒"的单次触发。错过的场景真实存在:闹钟被系统推迟、用户在提醒窗口内才解锁手机、进程被杀。ensureActiveFluidCloud() 补这个洞。它做的是状态判断:当前时刻落在某节课的 [开始时间-提前分钟, 开始时间] 窗口内,且主开关、课前提醒、流体云三个开关全开,就立即拉起 FluidCloudService。
调用点有三处:App 进程回前台(SleepyApp 注册的 ProcessLifecycleOwner 监听)、scheduleAll 排完课前闹钟之后、WidgetUpdateWorker 的 15 分钟周期任务。窗口已过的课不拉,服务自己会判过期并退出。
这个机制同样是状态判断,不补发闹钟:错过的那次普通横幅不会补,只有流体云形态能在窗口内被拉起。学期外、未绑定课表、三开关任一关闭,判断都不通过,直接返回。
| 权限 | 用途 |
|---|---|
POST_NOTIFICATIONS |
Android 13+ 发通知,主开关打开时申请 |
SCHEDULE_EXACT_ALARM / USE_EXACT_ALARM
|
精确闹钟,未授予时降级为非精确 |
RECEIVE_BOOT_COMPLETED |
开机重排 |
FOREGROUND_SERVICE + FOREGROUND_SERVICE_SPECIAL_USE
|
流体云前台服务(service 类型 specialUse) |
POST_PROMOTED_NOTIFICATIONS |
流体云推广通知(超级岛) |
WAKE_LOCK |
前台服务期间保持唤醒 |
源码:
app/src/main/java/com/lingion/sleepy/widget/notification/CourseNotificationScheduler.kt(调度器 + 四个 Receiver)、FluidCloudService.kt(流体云前台服务)、ui/screen/mine/ReminderScreen.kt(设置页)、util/AppPrefs.kt(开关存储与默认值)、data/repository/ScheduleRepository.kt(删表清理与数据变更联动)、SleepyApp.kt(回前台监听)、AndroidManifest.xml(Receiver/Service/权限声明)
Sleepy Wiki
入门
界面与视图
课程管理
导入导出
小组件与提醒
数据与设置
项目
社区