Skip to content

Notifications

lingion edited this page Sep 12, 2026 · 1 revision

Reminders

本文基于 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_masterfalse,取消全部闹钟。每日提醒、课前提醒、横幅、流体云这四个子开关的存储值原样保留。重新打开主开关,所有子配置原样恢复。调度器和各个 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",只有 DailyNotifyReceiverBeforeClassScheduleReceiver 不导出。开机或 App 更新后重排,前提是主开关开着;关着就什么都不做,等用户自己打开。

Android 12+ 的降级

Manifest 声明了 SCHEDULE_EXACT_ALARM。系统授予精确闹钟权限时,每类闹钟走精确路径:setRepeating / setExactAndAllowWhileIdle。未授权时自动降级:setInexactRepeating / set。降级后通知仍会到,只是可能晚几分钟,取决于系统批处理。

删表为什么必须显式 cancel

删除课表走外键 CASCADE,课程行跟着表一起没了。scheduleAll 内部的 cancelAll 靠查库枚举"现存课程"来取消课前闹钟——课程已删,枚举不到,对应的当天闹钟就成了孤儿,时间一到照样响。ScheduleRepository.deleteTable 因此在删除前先抓下该表全部课程 id,删完立刻调 cancelCourseAlarms(孤儿id) 显式取消。直接删课不走这条路,onDataChanged 里的 scheduleAll 能覆盖到。

排课口径

scheduleTodayBeforeClassAlarms() 排当天课前闹钟时按这套规则筛选:

  • 只排当前绑定的课表(WidgetTableResolver.resolveCurrentTable()),其他表再满也不排。
  • 今天不在学期范围内(开学前/结课后)直接返回,一节都不排。周数不做钳制,防止学期外误匹配第 1 周的课。
  • 课程开始时间取自定义时间(ownTimestartTime 非空)或作息表节点时间;两者都拿不到、或小时分钟越界,跳过这一节。
  • 通知时刻已过(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/权限声明)

相关页面

Clone this wiki locally