Skip to content

Latest commit

 

History

History
330 lines (235 loc) · 29.4 KB

File metadata and controls

330 lines (235 loc) · 29.4 KB

功能线 Review 路线图

项目按功能拆成 9 条线,每条只涉及 2-5 个文件。按"容易出 Bug 的程度 + 重要程度"排序,逐条 Review,比盯着全部代码轻松得多。

每次只看一条线,只看涉及的 2-5 个文件,跟着数据流向走一遍:谁发起 → 经过谁 → 谁处理 → 谁响应。


① 链式任务调度线(最核心,最复杂)

做什么:启动 → 算时间 → 倒计时 → 到点打开 APP → select{超时|打卡} 竞态 → 推进下一个 → 循环直到全部完成

涉及文件

  • TaskScheduler.kt — 调度核心,自包含全部逻辑
  • FloatingWindowController.kt — 超时倒计时 tick 通过 _timeTick SharedFlow 更新悬浮窗
  • MainActivity.kt(部分)— 订阅 isRunning / tipsEvent / returnToApp 三个 Flow 驱动 UI

架构要点

  • startTask() 启动一个 while(isActive) 持久协程:executeSchedule()waitUntilNextReset() → 循环
  • executeSchedule()for 链式执行:阶段1 countdown → 阶段2 openApplication() + select{...} → 阶段3 推进
  • select{} 竞态:timeoutJob.onJoin { false } vs clockInDeferred.onAwait { true }notifyClockIn() 完成 Deferred
  • 倒计时用 SystemClock.elapsedRealtime() 自校准,休眠唤醒后剩余时间准确
  • 超时路径通过 _returnToApp SharedFlow 通知 MainActivity 回到主页

重点关注

风险点 描述
clockInDeferredselect{} 内创建 notifyClockIn() 调用时 clockInDeferred 为 null 则 complete() 是空操作,但重新赋值进入 select 时 Deferred 已完成 → 分支 B 立即返回 true(正确)
超时兜底截屏 tick <= 5 最后 5 秒只触发一次(hasCaptured 标志),但如果截屏服务挂了,截屏悄无声息地失败

测试路线

# 场景 操作 预期结果 看什么 状态
1 正常完整链路 配置 1 个任务 → 打开目标 APP → 等倒计时归零 → 进入 APP → APP 触发极速打卡 倒计时归零打开 APP,NotificationMonitorService 识别通知 → TaskScheduler 收到 notifyClockIn → 自动回到主页后高亮下一个任务 悬浮窗倒计时数字、APP 是否被拉起、任务列表状态 🟢️测试通过
2 超时兜底截屏 配置 1 个任务 → 倒计时归零进入 APP → 一直不打卡,等待超时 最后 5 秒触发截屏 → 保存截图 → 超时后回到主页 → 自动发送兜底截图给用户 → 继续下一个任务 截图文件是否存在、超时后是否回到主页、任务是否推进 🟢️测试通过
3 中途停止任务 任务执行中 → 通过悬浮窗通知发送"终止任务" TaskScheduler 停止,任务停止,按钮状态重置 按钮状态、协程是否正确取消 🟢️测试通过
4 多任务链式 配置 3 个任务(不同时间)→ 启动 → 依次等待每个任务完成 每个任务按时间顺序执行,一个完成后自动推进到下一个 任务列表状态依次更新 🟢️测试通过️
5 空任务列表 清空所有任务 → 点击启动 提示无任务或直接不可启动 不崩溃、给出合理提示 🟢️测试通过️

② 打卡检测 & 远程指令线

做什么:监听通知栏 → 识别"打卡成功" → emitMonitorEvent(ClockInSuccess) → MainActivity 通过 SharedFlow 收集 → TaskScheduler.notifyClockIn();识别 DT# 前缀开头的"执行任务/终止任务/息屏/亮屏/截屏/考勤记录/状态查询"等远程指令(必须以 DT# 开头,否则忽略)

涉及文件

  • NotificationMonitorService.kt — 全部逻辑(events: SharedFlow<MonitorEvent> 对外通信)
  • MainActivity.kthandleMonitorEvent 部分)— 订阅 NotificationMonitorService.events

通信方式

  • NotificationMonitorService.events SharedFlow → MainActivity(6 种 MonitorEvent)
  • NotificationMonitorService.listenerState SharedFlow → SettingsActivity(UI 状态)

重点关注

风险点 描述
handleRemoteCommand()when { } 通知内容同时包含多个关键词时会命中第一个,可能不是用户意图
打卡检测依赖通知内容包含"成功" 如果 APP 更新了通知文案,就检测不到了
events SharedFlow extraBufferCapacity = 2 如果 MainActivity 未收集(被杀死),最多缓存 2 个事件,超出的丢失

测试路线

# 场景 操作 预期结果 看什么 状态
1 打卡成功通知识别 让目标 APP 弹出"打卡成功"通知(如钉钉打卡成功) NotificationMonitorService 捕获通知 → 发射 ClockInSuccess → MainActivity 收集到事件 logcat 搜索 ClockInSuccessnotifyClockIn 🟢️测试通过
2 远程"执行任务"指令 从另一台手机通过QQ、微信、支付宝或者TIM给自己发一条消息,内容包含"DT#执行任务" 触发 startTask,相当于点击了启动按钮 任务是否开始执行 🟢️测试通过️
3 远程"终止任务"指令 任务执行中 → 发送内容含"DT#终止任务"的通知 任务停止 按钮恢复初始 🟢️测试通过️
4 远程"息屏"指令 发送内容含"DT#息屏"的通知 MaskViewController 显示蒙层 屏幕被遮罩覆盖、时钟动画启动 🟢️测试通过️
5 远程"亮屏"指令 在息屏状态 → 发送内容含"DT#亮屏"的通知 蒙层消失 屏幕恢复可见 🟢️测试通过️
6 远程"截屏"指令 发送内容含"DT#截屏"的通知(确保 MediaProjection 已就绪) 触发截屏 → 保存文件 截图文件生成 🟢️测试通过️
7 远程"考勤记录"指令 发送内容含"DT#考勤记录"的通知 发送当日打卡记录消息(企业微信 / 邮箱) 是否收到统计消息 🟢️测试通过️
8 远程"状态查询"指令 发送内容含"DT#状态查询"的通知 回复当前运行状态(哪些服务在跑、当前时间、电量等) 返回的状态信息是否准确 🟢️测试通过️

③ 截屏服务线

做什么:MediaProjection 授权 → VirtualDisplay + ImageReader → 收到 CaptureScreen 事件 → 截图 → 裁剪上半部分 → 检测黑色画面 → 重试 → 保存文件

涉及文件

  • CaptureImageService.kt — 全部逻辑
  • ProjectionSession.kt — MediaProjection 生命周期管理
  • MainActivity.kt(部分)— 遥控截屏时通过 captureResults SharedFlow 等待结果
  • SettingsActivity.kt(部分)— 手动测试截屏

重点关注

风险点 描述
onStartCommand 失败时死循环 resultCode = RESULT_CANCELED 返回 START_STICKY,服务反复重启
isBitmapMostlyBlack() 采样步长 10 + 阈值 90% 如果截图正好是暗色 UI(如暗黑模式聊天界面),可能误判为黑屏
ImageReaderRGBA_8888 部分 OEM 的 VirtualDisplay 对 RGBA 支持不好,可能返回黑色帧,导致不断重试
waitForImageAvailablewithTimeoutOrNull(2000) 2 秒超时可能在低端机上不够

测试路线

# 场景 操作 预期结果 看什么 状态
1 正常截屏 SettingsActivity → 点击"测试截屏" 截图保存成功 截图文件存在、文件大小 > 0、画面正确(上半部分) 🟢️测试通过️
2 拒绝授权 弹出授权弹窗 → 点击"拒绝" ProjectionFailed 事件发出,SettingsActivity 显示失败 不崩溃、提示授权失败 🟢️测试通过️
3 遥控截屏 先确保 MediaProjection 已授权 → 发送"DT#截屏"远程指令 CaptureImageService 收到 CaptureScreen 事件 → 截屏成功 截图文件生成 🟢️测试通过️
4 黑屏画面重试 截屏时故意让屏幕显示全黑内容(如打开全黑图片) isBitmapMostlyBlack 检测为黑色 → 重试 logcat 搜索重试日志、最终是否放弃或成功 🟢️测试通过️
5 连续多次截屏 快速连续触发 3-5 次截屏(手动测试 + 远程指令) 每次截屏独立完成,不互相干扰 截图文件数量正确、无遗漏 🟢️测试通过

④ 伪息屏(MaskView)线

做什么:显示全屏遮罩 → 隐藏真实界面 → 时钟随机移动模拟熄屏显示 → 手势/音量键切换

涉及文件

  • MaskViewController.kt — 蒙层控制
  • GestureController.kt — 滑动手势检测
  • MainActivity.kt(部分)— 音量键、onNewIntent

重点关注

风险点 描述
clockAnimationRunnable 递归 postDelayed 30000ms 如果 stopClockAnimationstartClockAnimation 竞争,可能创建多个并行 Runnable
GestureController.minFlingDistance = 1000f 1000px 的滑动距离在有些屏幕上太长,可能划不动
MaskViewController 持有 insetsController 是构造时传入的 Activity 配置变更后可能失效
showMaskView()hideMaskView() 的动画取消操作 currentAnimation?.cancel() 后立即创建新动画,cancel 回调可能干扰

测试路线

# 场景 操作 预期结果 看什么 状态
1 手势滑动解锁 蒙层显示中 → 在屏幕中间向上滑动 > 1000px GestureController 检测到滑动 → 触发亮屏 → 蒙层消失 滑动灵敏度、误触是否被忽略 🟢️测试通过
2 音量键切换 蒙层显示中 → 按音量下键 → 再按一次 第一次:亮屏(蒙层消失);再按一次:息屏(蒙层显示) 音量键事件是否正确拦截、系统音量不变化 🟢️测试通过
3 远程"息屏/亮屏" 发送"DT#息屏"通知 → 等 3 秒 → 发送"DT#亮屏"通知 蒙层按指令显示/隐藏 与②联动的正确性 🟢️测试通过
4 时钟动画移动 蒙层显示中 → 等待 > 30 秒 时钟文字位置随机变化(每 30 秒 postDelayed 一次) 位置确实变了、不卡在同一个位置 🟢️测试通过
5 快速切换 连续快速按音量下键按钮 5 次 动画不冲突、不出现两个蒙层叠加 无异常 Crash、最终状态与最后操作一致 🟢️测试通过

⑤ 悬浮窗线

做什么:显示悬浮窗 → 拖动 → 倒计时显示 → 内存使用监控 → 超标预警

涉及文件

  • FloatingWindowService.kt — 悬浮窗主体,订阅 FloatingWindowController 的 3 个 SharedFlow
  • FloatingWindowController.kt — 外部控制接口(timeTick / overtime / visibility

谁在驱动悬浮窗

  • TaskScheduler.executeSchedule() 超时倒计时 → FloatingWindowController.updateTime(tick)
  • NotificationMonitorService 遥控"DT#打卡"独立倒计时 → FloatingWindowController.updateTime()
  • MaskViewController.showMaskView/hideMaskViewFloatingWindowController.hide/show()
  • TaskConfigActivity 修改超时时间 → FloatingWindowController.setOvertime()
  • MainActivity 遥控"截屏"倒计时 → FloatingWindowController.updateTime()

重点关注

风险点 描述
lateinit binding + START_STICKY 服务重启不调 onCreate,binding 未初始化
内存监控 delay(1000) 省电模式下 60000 省电模式每 60 秒检查一次,内存可能已经爆了才报警
onDestroycancel() CoroutineScope by CoroutineScope(Dispatchers.Main) 的实现,如果 cancel 先于协程完成,可能残留
windowManager.addViewonCreate 如果 App 在后台时 Service 被 START_STICKY 重启,onCreate 可能不被调用
多个协程同时 collect timeTick FloatingWindowService 里 3 个 launch 各 collect 不同 Flow,互不干扰;但只启动一次 Service

测试路线

# 场景 操作 预期结果 看什么 状态
1 拖动悬浮窗 按住悬浮窗 → 拖动到屏幕任意位置 → 松手 悬浮窗停留在新位置、不弹回原位 拖动流畅度、松手后位置锁定 🟢️测试通过
2 倒计时数字更新 观察任务执行中悬浮窗显示的剩余秒数 数字每秒递减,与实际倒计时一致 数字变化频率、与 TaskScheduler 的 tick 同步 🟢️测试通过
3 超时时间修改 TaskConfigActivity 修改超时时间 → 返回主页面 悬浮窗的倒计时上限更新为新值 overtime Flow 是否被正确 collect 🟢️测试通过
4 内存监控告警 模拟高内存占用(打开多个大型 APP)→ 等待内存监控周期 发送内存超标消息(企业微信或者QQ邮件) 内存值是否正确、告警阈值是否合理 ⚠️暂未测试
5 省电模式 系统设置中开启省电模式 → 观察内存监控频率 监控间隔从 1 秒变为 60 秒 模式切换是否实时生效 🟢️测试通过
6 息屏时隐藏 任务运行中 → 触发息屏(蒙层显示) 悬浮窗自动隐藏 与④的联动正确:MaskViewController.showMaskViewFloatingWindowController.hide() 🟢️测试通过

⑥ 任务重置线

做什么:每天到设定时间 → 重置任务状态 → 重新启动链式调度

涉及文件

  • TaskScheduler.kt — 协程 delay 主力调度(waitUntilNextReset
  • ForegroundRunningService.ktACTION_TIME_TICK 每分钟兜底 + 倒计时显示

架构要点

  • 主力:TaskScheduler.waitUntilNextReset() 用协程 delay 精确等待到次日重置时间
  • 兜底:ForegroundRunningService 订阅 ACTION_TIME_TICK,每分钟检查一次是否到达重置时间,防止长时间休眠后 delay 偏移
  • checkAndTriggerReset() 收拢到 ForegroundRunningService.kt,作为重置逻辑的唯一入口

重点关注

风险点 描述
checkAndTriggerReset()currentHour !in resetHour..(resetHour + 1) 如果 resetHour=23,范围是 23..24,Calendar.HOUR_OF_DAY 最大 23,边界没问题。但如果 resetHour=0,范围 0..1,正常
resetTaskSeconds()currentHour == hour && currentMinute == 0 && currentSecond == 0 时算明天 只有刚好在 00:00:00 这一秒才触发,几乎不可能命中
协程 delay 长时间休眠后唤醒 系统 Doze 模式可能导致 delay 偏差,ACTION_TIME_TICK 兜底只能在亮屏时收到广播

测试路线

# 场景 操作 预期结果 看什么 状态
1 修改重置时间 TaskConfigActivity 修改重置时间 → 保存 FGService 更新 resetTickTime 倒计时,waitUntilNextReset 按新时间计算 悬浮窗倒计时文字同步更新,delay 等待时间更新 🟢️测试通过
2 时间兜底触发 修改系统时间到次日重置时间前后 ACTION_TIME_TICK 到达重置时间时触发 checkAndTriggerReset 不会启动两次调度、不出现重复任务执行 🟢️测试通过️

⑦ 消息通知线

做什么:打卡结果 / 错误信息 → MessageDispatcher 根据用户配置自动分流 → QQ 邮箱(EmailManager )或企业微信 Webhook(RetrofitServiceManager)→ 异步发送

涉及文件

  • MessageDispatcher.kt — 统一消息分发入口(全局 object),封装渠道分流逻辑、元数据拼接、企微响应处理,全局复用协程作用域
  • EmailManager.kt — QQ 邮箱 SMTP 发送,支持普通邮件和带附件邮件,loadEmailConfig() 提取公共配置加载
  • RetrofitServiceManager.kt — 企业微信 Webhook API 调用(文本消息 / 图片消息),基于 Retrofit suspend 函数
  • MessageChannelActivity.kt — 消息渠道配置界面(QQ 邮箱 + 企业微信 Key)

架构要点

  • MessageDispatcher 是全局 object,通过 DailyTaskApplication.onCreateinitialize() 拿到 BatteryManager
  • 所有调用方统一走 sendMessage() / sendAttachmentMessage(),内部根据 SaveKeyValues 中存储的渠道配置分流
  • sendMessage 入口统一拼接元数据(标题、正文、日期、电量、版本号),保证邮件和企微正文一致
  • 邮件通过 EmailManager.sendAsync() 在内部 CoroutineScope(IO) 异步发送,回调切到 Main 线程
  • 企微通过 RetrofitServiceManager suspend 函数在 MessageDispatcher.scope 中调用, handleWechatResponse 统一处理响应

重点关注

风险点 描述
initialize() 未调用时 batteryManagerlateinit 需确保 DailyTaskApplication.onCreate 中先调用,否则 sendMessage 直接抛 UninitializedPropertyAccessException
EmailManager.sendEmail 配置缺失时回调 onFailure,不抛异常 若调用方未传 onFailure,静默失败,无法感知
企业微信图片消息 2MB 限制 RetrofitServiceManager.sendImageMessage 检测超限后降级为文本提示,截图分辨率较高时可能被拒绝
handleWechatResponse 回调均为 null 时提前 return 避免无意义的 JSON 解析和线程切换,守卫逻辑正确

测试路线

# 场景 操作 预期结果 看什么 状态
1 企业微信发送 配置好企业微信 Webhook Key → 触发一条打卡结果消息 企微收到消息,正文含日期/电量/版本号 元数据完整、格式正确 🟢️测试通过
2 QQ邮箱发送 配置好 SMTP 邮箱 → 触发一条通知消息 邮箱收到邮件,标题和正文正确 邮件标题和内容一致 🟢️测试通过
3 无网络发送 关闭 WiFi 和移动数据 → 触发消息发送 消息发送失败但不 Crash 超时后优雅失败、有错误日志 🟢️测试通过
4 渠道切换 企微 → 邮箱 → 再发送 新消息走新渠道 切换后无旧渠道残留 🟢️测试通过

⑧ 配置 & 数据线

做什么:任务的增删改查 + 邮箱配置持久化 + SharedPreferences 读写

涉及文件

  • DatabaseWrapper.kt — Room/数据库封装
  • TaskDataManager.kt — 任务导入导出
  • ConfigStore.kt — JSON 文件配置存储(邮箱等)
  • SaveKeyValues.kt(lite 模块)— SharedPreferences 封装
  • Constant.kt — 所有 Key 定义

重点关注

风险点 描述
ConfigStoreReentrantReadWriteLock 每次 save() 都写文件,频繁操作时 I/O 压力大
DatabaseWrapper 可能是静态方法 需要确认 Room 的线程安全是否正确使用
SaveKeyValuesConfigStore 两套存储 为什么用两套?是否有关联数据需要跨存储同步?

测试路线

# 场景 操作 预期结果 看什么 状态
1 任务 CRUD TaskConfigActivity → 添加一个任务 → 编辑 → 删除 数据库持久化正确,重启 APP 后数据仍在 列表刷新正确、删除后序号重新排列 🟢️测试通过
2 邮箱配置 SettingsActivity → 配置 SMTP 信息 → 保存 → 重启 APP 配置持久化,重启后不丢失 ConfigStore 写入成功、读取一致 🟢️测试通过
3 任务导入导出 导出当前任务列表为 JSON → 清空 → 重新导入 导入后任务列表与导出前完全一致 JSON 格式正确、所有字段完整 🟢️测试通过
4 并发读写 快速多次切换页面、同时修改配置和任务 数据一致性正确,无脏读脏写 Room 事务正确、ReentrantReadWriteLock 不阻塞 UI 🟢️测试通过

⑨ 保活 & 前台服务线

做什么:ForegroundRunningService 降低被杀概率 + 电量监控 + 低电量告警

涉及文件

  • ForegroundRunningService.kt — 全部逻辑
  • DailyTaskApplication.kt — 应用初始化

重点关注

风险点 描述
BroadcastReceiveronCreate 注册 START_STICKY 服务被杀重启时可能重复注册(虽然 onCreate 不一定被再调,但需要确认)
checkLowBattery() ACTION_BATTERY_CHANGED 是 sticky broadcast,注册时会立刻收到一次。但 onCreate 结尾也手动调了一次,所以初始化时检查了两次
低电量提醒冷却 5 * 60 * 1000 5 分钟内只提醒一次,但如果电量在 19% 和 20% 之间反复横跳,会漏报

测试路线

# 场景 操作 预期结果 看什么 状态
1 前台服务启动 APP 启动后 → 下拉通知栏 通知栏显示"每日任务运行中"的前台服务通知 通知存在且内容正确 🟢️测试通过
3 低电量提醒 1 电量降到 20% 以下 → 观察 触发低电量通知提醒 通知内容、提醒时机 ⚠️暂未测试
4 低电量提醒 2(冷却期) 电量 20% 以下 → 插上充电器 → 拔掉 → 再次低于 20%(5 分钟内) 不重复提醒 冷却机制生效 ⚠️暂未测试
5 低电量恢复 电量低于 20% → 插上充电器充到 30%+ 低电量状态解除,不持续告警 无多余提醒 ⚠️暂未测试
6 重置倒计时更新 修改重置时间 → 回到主页面 resetTickTime SharedFlow 更新 MainActivity 的倒计时文字 显示的文字与 FGService 内部计算一致 🟢️测试通过
7 全天运行稳定性 保持 APP 运行 24 小时(或至少 8 小时) 服务不异常退出、通知不消失、不 ANR 内存占用稳定、无 OOM 🟢️测试通过

① 链式任务调度 开始深入 Review,这是整个项目的骨架,也是 bug 最密集的地方。