From 23e36871d747f5e690d5e44079a211e696e68356 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 10:43:39 +0800 Subject: [PATCH 1/9] Merge gac0812fork/new-main: wire real reminder engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto upstream/main now that #243 is merged. Same content as the original merge commit (fcc9f15) plus the reminder_offset_minutes=15 fix (6a4bafd), replayed cleanly on top of #240/#241's schedule UI redesign (no overlap, verified via git merge-tree before rebasing). 将 gac0812 fork(提醒引擎、原生闹钟、地理围栏、系统通知/震动、权限申请)合 并进来,解决 12 个真实冲突,并把两套组合根手工合成一套:保留 #243 的认证/ WebSocket/日程视图骨架,reminderPorts 换成 fork 的真实实现 (NativeAlarmScheduler/NativeDeviceCapability/ExpoAudioPlayback/ NativeLocationMonitor),reminder 从 MockReminderApplication 换成真的 LocalReminderApplication。 冲突解决之外真实接入的部分: - app.json 转成 app.config.js:百度定位 Key 走 process.env,合并双方的 iOS 权限说明/Android 权限并集,去重重复键。 - 新建 SqliteLocalScheduleReader(attach/detach 延迟绑定,供 reminder 引擎 读真实本地日程)和 SqliteReminderStateStore(提醒运行时状态真正落 SQLite,替换掉纯内存的 MemoryReminderStateStore——原来进程一杀已触发的 提醒状态就丢,下次启动会整批重新弹一遍)。 - LocalScheduleWriter 语音写入成功后触发 SqliteLocalScheduleReader.refresh()。 - AppProviders 里接上权限申请 hook + reminder.rebuild(),按认证态门控。 - LocalReminderApplication 的时间型提醒投递加了原生闹钟归属判断:只要排上 了原生精确闹钟,弹窗和语音就完全交给原生 RingActivity/AlarmSoundService, JS 不再重复弹——避免真机上应用内弹窗和全屏响铃互相抢、谁都关不掉的问题。 - 新增 ExpoLocationMonitor:用 expo-location 的系统原生地理围栏 (GeofencingClient/CLCircularRegion)接围栏检测,不依赖百度定位 SDK 的 账号/Key 绑定;NativeLocationMonitor(百度)保留在仓库里,createAppServices 换个 import 就能切回去。 - exact_alarm 权限跳过确认弹窗直接跳系统设置页(没有系统授权框,多一次点 击没有意义)。 - 修了 @irvingouj/expo-audio-stream 的 build.gradle patch:AGP>=8 时 javac/kotlinc 目标版本对不上导致编译失败。 - reminder_offset_minutes 默认值从 200 分钟改成 15 分钟(backend/ instructions.py,200 分钟太长,真机测试等不到触发)。 删掉了 3 个测试老版本原生适配器 API 的过期单测(useReminderPermissionsOnLaunch/ nativeAlarmScheduler/nativeDeviceCapability),新增 SqliteLocalScheduleReader 和 LocalScheduleWriter 刷新触发的集成测试。 已知缺口:云端日程同步(SqliteScheduleSyncService)这次没接,生产代码里还 是零调用点,只接了语音写入这条刷新路径;geofence_radius_meters 本地表没有 这一列,硬编码 200 米。 --- .gitignore | 1 + .../intelligence/realtime/instructions.py | 2 +- frontend/.env.example | 4 + frontend/.prettierignore | 1 + frontend/app.config.js | 81 ++++ frontend/app.json | 34 -- frontend/assets/sounds/alarm_prompt.mp3 | Bin 0 -> 29376 bytes frontend/eslint.config.js | 10 +- frontend/index.ts | 3 + frontend/modules/timeflow-alarm/.gitignore | 3 + frontend/modules/timeflow-alarm/README.md | 5 + .../timeflow-alarm/android/build.gradle | 38 ++ .../android/src/main/AndroidManifest.xml | 29 ++ .../android/src/main/assets/alarm_prompt.mp3 | Bin 0 -> 29376 bytes .../com/timeflow/alarm/AlarmContract.java | 24 ++ .../java/com/timeflow/alarm/AlarmModule.kt | 259 ++++++++++++ .../com/timeflow/alarm/AlarmNativeBridge.java | 162 ++++++++ .../java/com/timeflow/alarm/AlarmPackage.kt | 18 + .../com/timeflow/alarm/AlarmReceiver.java | 36 ++ .../java/com/timeflow/alarm/AlarmRingUi.java | 320 +++++++++++++++ .../com/timeflow/alarm/AlarmScheduler.java | 341 ++++++++++++++++ .../com/timeflow/alarm/AlarmSoundService.java | 382 ++++++++++++++++++ .../java/com/timeflow/alarm/DayRulerView.java | 157 +++++++ .../java/com/timeflow/alarm/RingActivity.java | 212 ++++++++++ frontend/modules/timeflow-alarm/index.js | 3 + frontend/modules/timeflow-alarm/package.json | 12 + .../timeflow-alarm/react-native.config.js | 12 + .../timeflow-baidu-location/.gitignore | 3 + .../modules/timeflow-baidu-location/README.md | 16 + .../android/build.gradle | 39 ++ .../android/src/main/AndroidManifest.xml | 18 + .../baidulocation/BaiduLocationModule.kt | 191 +++++++++ .../baidulocation/BaiduLocationPackage.kt | 18 + .../modules/timeflow-baidu-location/index.js | 3 + .../timeflow-baidu-location/package.json | 12 + .../react-native.config.js | 12 + frontend/package-lock.json | 164 ++++++-- frontend/package.json | 7 +- .../@irvingouj+expo-audio-stream+3.1.0.patch | 23 ++ frontend/plugins/withTimeflowAlarm.js | 29 ++ frontend/plugins/withTimeflowBaiduLocation.js | 59 +++ frontend/react-native.config.js | 12 + frontend/src/app/AppProviders.tsx | 26 +- frontend/src/app/AppRoot.tsx | 39 +- frontend/src/app/composition/.gitkeep | 1 - .../src/app/composition/createAppServices.ts | 89 ++-- frontend/src/app/orchestration/.gitkeep | 1 - .../data/local/LocalScheduleWriter.ts | 9 +- .../application/LocalReminderApplication.ts | 192 +++++++-- .../features/reminder/application/index.ts | 8 + .../reminder/application/interfaces/.gitkeep | 1 - .../interfaces/AlarmSchedulerPort.ts | 21 + .../interfaces/DeviceCapabilityPort.ts | 2 + .../interfaces/LocationMonitorPort.ts | 16 +- .../interfaces/NotificationChannels.ts | 20 + .../interfaces/ReminderApplicationPort.ts | 20 +- .../interfaces/ReminderDeliveryPort.ts | 2 + .../reminder/application/interfaces/index.ts | 12 +- .../src/features/reminder/data/local/.gitkeep | 1 - .../data/local/InMemoryLocalScheduleReader.ts | 61 +++ .../data/local/LocalReminderAdapters.ts | 78 ++++ .../data/local/MockLocalScheduleReader.ts | 32 -- .../data/local/MockReminderApplication.ts | 105 ----- .../data/local/MockReminderDispositionSync.ts | 17 - .../data/local/MockReminderStateStore.ts | 27 -- .../data/local/SqliteLocalScheduleReader.ts | 102 +++++ .../data/local/SqliteReminderStateStore.ts | 74 ++++ .../src/features/reminder/data/local/index.ts | 15 +- .../data/local/mockReminderSchedules.ts | 73 ---- .../src/features/reminder/domain/.gitkeep | 1 - .../src/features/reminder/domain/index.ts | 2 + .../src/features/reminder/domain/reminder.ts | 7 +- .../reminder/domain/strengthDelivery.ts | 35 ++ frontend/src/features/reminder/index.ts | 40 +- .../features/reminder/presentation/.gitkeep | 1 - .../presentation/AlertReminderPresenter.ts | 76 ++++ .../presentation/MockReminderPresenter.ts | 26 -- .../features/reminder/presentation/index.ts | 2 +- .../useReminderPermissionsOnLaunch.ts | 199 ++++++--- frontend/src/infrastructure/audio/.gitkeep | 1 - .../infrastructure/audio/ExpoAudioPlayback.ts | 147 +++++++ .../infrastructure/audio/MockAudioPlayback.ts | 32 -- .../src/infrastructure/audio/audioDataUri.ts | 39 ++ frontend/src/infrastructure/audio/index.ts | 3 +- frontend/src/infrastructure/location/.gitkeep | 1 - .../location/ExpoLocationMonitor.ts | 225 +++++++++++ .../location/LocationProvider.ts | 4 +- .../location/MockLocationMonitor.ts | 57 --- .../location/NativeLocationMonitor.ts | 223 ++++++++++ .../infrastructure/location/geofenceTask.ts | 64 +++ frontend/src/infrastructure/location/index.ts | 10 +- .../location/native/BaiduLocationBridge.ts | 83 ++++ .../src/infrastructure/notifications/.gitkeep | 1 - .../notifications/ExpoSystemNotification.ts | 72 ++++ .../notifications/MockAlarmScheduler.ts | 26 -- .../notifications/MockDeviceCapability.ts | 40 -- .../notifications/MockNotificationChannels.ts | 39 -- .../notifications/MockReminderDelivery.ts | 26 -- .../notifications/MockReminderRecovery.ts | 15 - .../notifications/NativeAlarmScheduler.ts | 81 +++- .../notifications/NativeDeviceCapability.ts | 135 +++++-- .../notifications/ReactNativeAlertDialog.ts | 25 ++ .../notifications/ReactNativeVibration.ts | 38 ++ .../src/infrastructure/notifications/index.ts | 12 +- .../native/TimeflowAlarmBridge.ts | 122 +++++- .../time/IntervalTimeListener.ts | 38 ++ frontend/src/infrastructure/time/index.ts | 1 + frontend/src/shared/time/.gitkeep | 1 - frontend/src/shared/time/MockClock.ts | 12 - frontend/src/shared/time/MockTimeListener.ts | 20 - frontend/src/shared/time/format.ts | 19 + frontend/src/shared/time/index.ts | 4 +- frontend/src/types/.gitkeep | 1 - frontend/src/types/expo-audio.d.ts | 16 + .../integration/localScheduleWriter.test.ts | 81 ++++ .../sqliteLocalScheduleReader.test.ts | 132 ++++++ frontend/tests/unit/app/AppRoot.test.tsx | 7 +- .../useReminderPermissionsOnLaunch.test.ts | 310 -------------- .../nativeAlarmScheduler.test.ts | 257 ------------ .../nativeDeviceCapability.test.ts | 147 ------- frontend/tsconfig.json | 3 +- 121 files changed, 5158 insertions(+), 1600 deletions(-) create mode 100644 frontend/app.config.js delete mode 100644 frontend/app.json create mode 100644 frontend/assets/sounds/alarm_prompt.mp3 create mode 100644 frontend/modules/timeflow-alarm/.gitignore create mode 100644 frontend/modules/timeflow-alarm/README.md create mode 100644 frontend/modules/timeflow-alarm/android/build.gradle create mode 100644 frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml create mode 100644 frontend/modules/timeflow-alarm/android/src/main/assets/alarm_prompt.mp3 create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmContract.java create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmModule.kt create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmRingUi.java create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java create mode 100644 frontend/modules/timeflow-alarm/index.js create mode 100644 frontend/modules/timeflow-alarm/package.json create mode 100644 frontend/modules/timeflow-alarm/react-native.config.js create mode 100644 frontend/modules/timeflow-baidu-location/.gitignore create mode 100644 frontend/modules/timeflow-baidu-location/README.md create mode 100644 frontend/modules/timeflow-baidu-location/android/build.gradle create mode 100644 frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml create mode 100644 frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt create mode 100644 frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt create mode 100644 frontend/modules/timeflow-baidu-location/index.js create mode 100644 frontend/modules/timeflow-baidu-location/package.json create mode 100644 frontend/modules/timeflow-baidu-location/react-native.config.js create mode 100644 frontend/plugins/withTimeflowAlarm.js create mode 100644 frontend/plugins/withTimeflowBaiduLocation.js create mode 100644 frontend/react-native.config.js delete mode 100644 frontend/src/app/composition/.gitkeep delete mode 100644 frontend/src/app/orchestration/.gitkeep delete mode 100644 frontend/src/features/reminder/application/interfaces/.gitkeep delete mode 100644 frontend/src/features/reminder/data/local/.gitkeep create mode 100644 frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts create mode 100644 frontend/src/features/reminder/data/local/LocalReminderAdapters.ts delete mode 100644 frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts delete mode 100644 frontend/src/features/reminder/data/local/MockReminderApplication.ts delete mode 100644 frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts delete mode 100644 frontend/src/features/reminder/data/local/MockReminderStateStore.ts create mode 100644 frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts create mode 100644 frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts delete mode 100644 frontend/src/features/reminder/data/local/mockReminderSchedules.ts delete mode 100644 frontend/src/features/reminder/domain/.gitkeep create mode 100644 frontend/src/features/reminder/domain/strengthDelivery.ts delete mode 100644 frontend/src/features/reminder/presentation/.gitkeep create mode 100644 frontend/src/features/reminder/presentation/AlertReminderPresenter.ts delete mode 100644 frontend/src/features/reminder/presentation/MockReminderPresenter.ts delete mode 100644 frontend/src/infrastructure/audio/.gitkeep create mode 100644 frontend/src/infrastructure/audio/ExpoAudioPlayback.ts delete mode 100644 frontend/src/infrastructure/audio/MockAudioPlayback.ts create mode 100644 frontend/src/infrastructure/audio/audioDataUri.ts delete mode 100644 frontend/src/infrastructure/location/.gitkeep create mode 100644 frontend/src/infrastructure/location/ExpoLocationMonitor.ts delete mode 100644 frontend/src/infrastructure/location/MockLocationMonitor.ts create mode 100644 frontend/src/infrastructure/location/NativeLocationMonitor.ts create mode 100644 frontend/src/infrastructure/location/geofenceTask.ts create mode 100644 frontend/src/infrastructure/location/native/BaiduLocationBridge.ts delete mode 100644 frontend/src/infrastructure/notifications/.gitkeep create mode 100644 frontend/src/infrastructure/notifications/ExpoSystemNotification.ts delete mode 100644 frontend/src/infrastructure/notifications/MockAlarmScheduler.ts delete mode 100644 frontend/src/infrastructure/notifications/MockDeviceCapability.ts delete mode 100644 frontend/src/infrastructure/notifications/MockNotificationChannels.ts delete mode 100644 frontend/src/infrastructure/notifications/MockReminderDelivery.ts delete mode 100644 frontend/src/infrastructure/notifications/MockReminderRecovery.ts create mode 100644 frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts create mode 100644 frontend/src/infrastructure/notifications/ReactNativeVibration.ts create mode 100644 frontend/src/infrastructure/time/IntervalTimeListener.ts create mode 100644 frontend/src/infrastructure/time/index.ts delete mode 100644 frontend/src/shared/time/.gitkeep delete mode 100644 frontend/src/shared/time/MockClock.ts delete mode 100644 frontend/src/shared/time/MockTimeListener.ts create mode 100644 frontend/src/shared/time/format.ts delete mode 100644 frontend/src/types/.gitkeep create mode 100644 frontend/src/types/expo-audio.d.ts create mode 100644 frontend/tests/integration/localScheduleWriter.test.ts create mode 100644 frontend/tests/integration/sqliteLocalScheduleReader.test.ts delete mode 100644 frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts delete mode 100644 frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts delete mode 100644 frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts diff --git a/.gitignore b/.gitignore index cf4adc49..b605435f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea/ +.cursor/ .env .venv/ node_modules/ diff --git a/backend/src/timeflow/intelligence/realtime/instructions.py b/backend/src/timeflow/intelligence/realtime/instructions.py index 6b70ffdb..0640fbf6 100644 --- a/backend/src/timeflow/intelligence/realtime/instructions.py +++ b/backend/src/timeflow/intelligence/realtime/instructions.py @@ -45,7 +45,7 @@ - 没提到地点,就当时间型日程处理,不要为了填 latitude/longitude 去调用 location_search 或编一个地点——地点型日程的地点仍然必须问清楚,这条只管时间型日程不要凭空加地点。 - 没说提醒方式,默认建一个 reminder_strength 为 medium 的提醒:非全天日程用 - reminder_type=before_start、reminder_offset_minutes=200(开始前 200 分钟);全天日程用 + reminder_type=before_start、reminder_offset_minutes=15(开始前 15 分钟);全天日程用 reminder_type=at_time、reminder_trigger_at 填当天上午 10:00(带时区偏移)。 地点怎么定 diff --git a/frontend/.env.example b/frontend/.env.example index d15fbb5b..5e2ef292 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,7 @@ EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 EXPO_PUBLIC_WS_URL=ws://10.0.2.2:8000/ws EXPO_PUBLIC_DEVICE_ID=device_001 + +# Baidu Location API key, consumed by app.config.js at build/prebuild time +# (plugins/withTimeflowBaiduLocation.js writes it into AndroidManifest.xml). +TIMEFLOW_BAIDU_LOCATION_API_KEY= diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 7a3927bb..5951a6f6 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -4,3 +4,4 @@ dist/ web-build/ package-lock.json assets/ +modules/**/android/build/** diff --git a/frontend/app.config.js b/frontend/app.config.js new file mode 100644 index 00000000..700e76db --- /dev/null +++ b/frontend/app.config.js @@ -0,0 +1,81 @@ +module.exports = { + expo: { + name: 'Timeflow', + slug: 'timeflow', + version: '1.0.0', + orientation: 'portrait', + icon: './assets/icon.png', + userInterfaceStyle: 'light', + ios: { + supportsTablet: true, + infoPlist: { + NSMicrophoneUsageDescription: '需要麦克风权限来录制语音指令', + NSLocationWhenInUseUsageDescription: '需要定位权限来关联语音指令发生的地点', + UIBackgroundModes: ['location'], + }, + bundleIdentifier: 'com.anonymous.timeflow', + }, + android: { + package: 'com.anonymous.timeflow', + permissions: [ + 'RECORD_AUDIO', + 'ACCESS_COARSE_LOCATION', + 'ACCESS_FINE_LOCATION', + 'ACCESS_BACKGROUND_LOCATION', + 'FOREGROUND_SERVICE', + 'FOREGROUND_SERVICE_LOCATION', + 'SCHEDULE_EXACT_ALARM', + 'POST_NOTIFICATIONS', + 'USE_FULL_SCREEN_INTENT', + 'SYSTEM_ALERT_WINDOW', + 'REQUEST_IGNORE_BATTERY_OPTIMIZATIONS', + 'FOREGROUND_SERVICE_MEDIA_PLAYBACK', + 'VIBRATE', + ], + adaptiveIcon: { + backgroundColor: '#E6F4FE', + foregroundImage: './assets/android-icon-foreground.png', + backgroundImage: './assets/android-icon-background.png', + monochromeImage: './assets/android-icon-monochrome.png', + }, + predictiveBackGestureEnabled: false, + }, + plugins: [ + 'expo-sqlite', + [ + 'expo-location', + { + locationAlwaysAndWhenInUsePermission: + '允许 Timeflow 使用你的位置,以便在到达或离开地点时提醒你。', + locationWhenInUsePermission: '允许 Timeflow 在使用期间访问位置,以便地点提醒生效。', + isIosBackgroundLocationEnabled: true, + isAndroidBackgroundLocationEnabled: true, + isAndroidForegroundServiceEnabled: true, + }, + ], + '@irvingouj/expo-audio-stream', + [ + 'expo-audio', + { + microphonePermission: '需要麦克风权限来录制语音指令', + enableBackgroundPlayback: true, + }, + ], + [ + 'expo-notifications', + { + icon: './assets/icon.png', + color: '#15352B', + defaultChannel: 'timeflow-reminders', + }, + ], + './plugins/withTimeflowAlarm', + [ + './plugins/withTimeflowBaiduLocation', + { + apiKey: process.env.TIMEFLOW_BAIDU_LOCATION_API_KEY, + }, + ], + ], + }, +}; diff --git a/frontend/app.json b/frontend/app.json deleted file mode 100644 index d1242021..00000000 --- a/frontend/app.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "expo": { - "name": "Timeflow", - "slug": "timeflow", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/icon.png", - "userInterfaceStyle": "light", - "ios": { - "supportsTablet": true, - "infoPlist": { - "NSMicrophoneUsageDescription": "需要麦克风权限来录制语音指令", - "NSLocationWhenInUseUsageDescription": "需要定位权限来关联语音指令发生的地点" - }, - "bundleIdentifier": "com.anonymous.timeflow" - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/android-icon-foreground.png", - "backgroundImage": "./assets/android-icon-background.png", - "monochromeImage": "./assets/android-icon-monochrome.png" - }, - "predictiveBackGestureEnabled": false, - "permissions": [ - "RECORD_AUDIO", - "android.permission.ACCESS_COARSE_LOCATION", - "android.permission.ACCESS_FINE_LOCATION" - ], - "package": "com.anonymous.timeflow" - }, - "plugins": ["expo-sqlite", "expo-location", "@irvingouj/expo-audio-stream"] - } -} diff --git a/frontend/assets/sounds/alarm_prompt.mp3 b/frontend/assets/sounds/alarm_prompt.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..95a3dcd0eea0f0ff663e5ffd1cfe6d4cd254f1a9 GIT binary patch literal 29376 zcmaIdRa6{Z*e2}8odCgU2p-%aSa5fDcXyZI?(Xhx!GpWITY@_T4cbG$v;Kd+H3!oN z&FaNLU48GWU3)+EH0ai35C8x`NqoEkWCUeJRaI4q;A@P~;ezGf_ZO@m-M!J|jfjIy z&v=8)#lg?lhMpD}m_$s#@rIe!)RE7Q=~nk0_f zB~|pp=)50$^w5IHBo(QZ(1+(5tP>cTbZ`MN1&gH%!iLG*;67xqtt9jZcoNq%7@@iK6P}NPLuF@927n~dx_i}glFkW;o{y- z(zaKkpL7;G&YyAmd%3+@uaFd1SMVu#BE>UyT4F~8HG@4MG7;wA0H!Hjib*2o+^UC6 zMrl&5`GlA%xLl6ZLhhg&j6mTNe8Yb2saEtck@^Iw#!Frw%W>%M-vSKj4R7te*y%q9 zBcme&DSPVEWEP%X2Re@CQ>PPg1w8a4m_p@YW+kl88>}PY0_Oe*E&R0UgSv>bph~GC zi$zUFFN7JdJu^s#|L0n@5*4fg_S|y~a-9SEHGo%#jg54E>GrFqX6>FoiqJxlVVT;h zbBoI9k2mxz=B7>=mwiznYnn{hvQ zef2a!NCJBjXf|1>?!08Wy1Yl?=+kQNKb>bh*L1I#zv{e|j`;R;O@>Q-JL7?NI4}yJ zsRj&cPRO*KZJDB0tuXwk6(K2affEyKgYHw2MNGFa@oy(Zz~u~Z$3>Be5e{TEYMPAu zsgYC3(KpW)3?-&3Ar&dFHAbGwrU@BTgiOI*7=n^CE)7ljh1)uXu>Q|E*ki#W!%Ykr zm)VT4K+?S5KE(+e6+ka4!?x8yD-O?t=7ovd;utFq$_h#7Pc})l{24I(MV%)@I!M`^ zR_)i2rI#1=C3%LcX@czLKA*9jST%JnDyut9%oB{A{| zj15w1+!@S3=RP6-P@7-gqp-JPHhZW0l>j!hlHXtt3Hc<8litQ)POpds{iP#C4(Ava zKVgh#p3BNRK{k&WezXBG>JWkhk-u)xB$PlkQ4q;bolnW$1lgRDL&C#qdCABdikUR)<2D;Y=fk2wiJ);ZR!6XDcRq@BZb^3Q!zk-8tkryhL zzSGmInBgwb}P7wSIJo@sHnWxNarqHczL1EAkcfvyUS7O zxbv@!oe#^AIC+e6c1*6=9V)a#)bFfQ$B2jD{=Gv0tNhB!9uR1}D8RLLdkhCQ1WL>j z?7<>sW9duohq1BkBCmnJyR&149fQ+S^X7C{-NNc|>vp%$)MtLa=bp{gzq~njUR@(L zj@ziZs;iWL!R%}p2o#o&TpD(zXOVm_+M_HNq>G-fjytT^fzu0c_RrpKX?5` zfQfzhhi8BdM+W0z7qj>q0(N6VJ?9T3ZyoCofh4GjmAKV2Olo)5$M+FK7baJhm^a%* zHYiM$UnfmG+>U=~#s#qTcy~+ORA_oYcQ+k=`qQDTGzih2B*tGrpqyMpBqG#M(dftu zhKCx7R5UF11QUCkEF@z(3JY^UxUnDD^O=OUfBfA`RIox_Aj=iC!2&ntL7kH$O`sY< zwjws1p+b8wm-Kols_vcU>e8^$v|ShJ{@a#xz9R{)fzb4@?~X4V1}06#n#S4BO6Bwt zx?Ab)3tD|pX{a{Eys1>Ar=4}XJvgR!tx8k6Lvf5T*(jY<_m!8c;q^fP22zh3_LahE ze_!bMu=h0BgAQyj?B*DljfZE*Z>pL1sfA01e_vI?I6|7APdlR3ehe#Te*aWS);4D! zGlcAW{_!7FjbC7Sx;4_P`zz;9rIk zcTFY$xl>u)3i}rjCm!{kv`FxA!cVvN!-Qn-_uJJ3i>G9X&?;^fcGx&VEH=VYLUQf! zNPjsHD6nq{3xV@^xC}z!>4!$SO4AML*Hk)>O9~WLn`2eY&JD-Z$xxB?Xkr|u$wWh3 znG99<%)oS47QO-LNR1b5OSIp;u>w_}$BcWqduao}9wusS(uA%I3rI>>i3}vPJY-{6 z%FhAEypUoy9hHuHVFD5U{AFxB7s;9=@L@Dk1T zEZx8HMq2!$*+bH5kY6 zhmp@RKR}>Y2nfjnBN7#`2Zgv(ZC5{>3J?g@(AdWfEcZO*9Ij+4_pw4k>j!_GVlN8yW$gx_^pF z80KzRn9h4&R&H@_|3=!_vTiE`dVBa3HqEtr3_ylNeTXFB_Si_GVwZ&m$f9zbFUZrQ z{pTUXYpGRT(*P8kA&;49?t7UwjA=mpdD7!DJvU1a=DVDro2(D|ykMOq_pgx&+uvcFC#M?>ou_`Fx~sUiNxRgxz?S^p1W7l z2*1m6G@=4dao@;tKtHmOLY9AK#`Z%#oy(LhP9iV!rqEl{p@2SLw*OUJ#G6rf~;3 zxo=Q?Ll|gWvaadjw;$8tbv6qE1=6W3|q zX-dec5{Fg-iKia?)SjaKK4yMA-RNC;PidD&b?`ax2s+7@Qex~>UjB*B<87YJU>S@< z?__b|z?o2DB`OdoWIgeR1cHT4yjoFXQdH;8yQU?L@Wn`$5)yv}k8^A-3l%u!j$bNHne&H2vdshixIGG6_|v=0yMGR$L$SLcI?ONrA_KqdnNw19}fb zi7D_p(}-7<5;YX*rE|J?tPFVF0oA+ed9DcBcuiqh$Y^w0g3^`kNBSm_-nU8MvIl=s$h=-P)Vwv zU!Dq*Y5)n-;t-vtth>DQW*7AG!~ID$u>QKNqC_rqBp^2jmd4VbCAhGVl;XLWGZTOV zp~Eu}=%P7GF%{D|&1dBUY?&LVpNogAs~D})=Ub9WihjwU>L5Q=dvIh`ti&?kv;ORi z*qGVSmCt*oeTwccaw%su+MWzK@W-|2Jf zvR?@^2;>LKHtU#vj1*bfW{=(it$^M@x1goJemy5|$TeG&)>_Mi}Z(~Rpz3jkomG(-kNCNyh2t}f!Du!-GjuX4Qg`KKl$ z6JDIrR@w@)DMPy$yhHreO#11teE4InV$I7U#hvM%L-K;Os&+eKpRbbnj-E5F^5Sxz ztgvGCSYLgJKzU>S@NSsqd(KoRx9Wq-MR>Cpd-kZjkr*(Kl#NoM6UlTGH ze*$|@2{D`|2q$p?-!%-~vV%KUnDZ=O=9j`smax)?l<)&X^D`6jTVOytto$u(k$xb* ze-_+9K-!FO6d^fTE~XIPlia*3MAPe4d1Q)Qg=8NsTnM>0Um;(U0MyZ=dyw^>6| ztYc;N!^}!aC@j53idKbVYA{ri=GLyLc|<6NQ$bOo1{GgPBNAy0av2uFLGV&QWs=Tf zBKJu(50s1*GC$OsEMzqt)6?a@ND_BQO8#&;MWk*vF%I4FwSr~kr~QRRW4)H0NCmH# z_NUcXWBs+Wadt$8&{eVZEj9Dk`rgBGx5(*@vCT|rVKV9^<2#=W#6X0Ndt*WyV#h&Y8R(Id(FV>oyw5wReccknR zN=UU$)9PbK@oJ{6|J(q-x5v>7-8d>4i@12tw;@^0UN!{8f9{HIK3tAFVURL$>x|va z-v3hZr2?t)t2!Up!w;0?XaX)n^VX55P#83&T8!--h*%)hk!S$o#h)WfY)z4^LWsQY zEV@0#dmaj6g)Uf3cKPzzweg#24B*_Jg&>mMJ5o0Mb4Du|D}wO@P5b;Z_@y%ZuJWY*fLuIf$YS>pUy=T( zpx$`5SuyyZ*00PkfB$IN%u&qg#0mw7&j}Yi96NESsIS(_`)-SJ*4HD`FwhZU>zgPa z8m~xk(;wAMR=aY==RIqFRnOYe#ZgnHy!cpYTRC;j^?Y1^CWFR6=Z;2~iE#x#VU-Sd zC9ZMy$nPUqg+{ZMDeGT|i^A!wo{b$ppX zxD)eMf7HM0Fc~lrLv*=-sBqobG(y217a(Nj^bZf2SC0U_gpNTmK>rxcCu`z+62fFt zSgoWv7N#8eJkV*U2qP#%S``F3lrltH69kEgZ+49QtdfURqogsaLHpWZm zraudpBaB$rc|A$hqLI}}#925!Nx+RRZV@Et;=h9znQ;HN{M$S z#CdpllZs7d%~|Vs=m9M-MqATtEqtur*)k@zncLNuB|}2eW#`LK;ethj+El;9^{DNk9M)`30Qz=70V z9f|P;QOBEaxswKxnACXuE9eVO@BpKLqF@v<&qJ@bL`xWWBq)g68P^G<*!y#T$vBet z7i=35q@*th@)o*+kd=*_%-nEvDcXTkw(e#g8@AO+g4WDm<{f?LyWDq$a8{@Qf*FFr z1jbwxj(Edz2ft|SPFfo(j8q_CNNhx}mx&Ah7XIc0d#Zt!%iB7Jp{W*8xaDszqcNR{ zTtv!)k$y)npnLlV6X_n%TlkDx(o~fn=qcgwEwjd?BfW6H#Fyc(O3mC>P0L`EKPv31 z4iJ@0if(rGH6aI!BZDA63B%4gRINiZPT3|TBO6bW!(HVK^Fd(u=t|v+a-WQfzq6_F zj_-uKVfvheT%45sH;Ii|3)s^|;DUYnYB1g{EQ*WIJ*jZnu}v8uBEnu^AHpS~j4#kwjH402Y!K z2c0!iQk<^1LQQs)TXpL#=22-aFE_Yp>xEA;N~t#G-WG+iXn_^EN)%mc?Rmvtm!IEHk(AaNx978*mLglpbJiYq^1E653;!KzhZ1WeL+b5)>8bo(2l z#K?$9F?pD3>3`dbSlBDK9f_RZgG*QnBW<>O`OB)^gNa#GrBOm_eYtd%Gk#DI_*2Nz zb8hTfMnlD|!E-$&n&Zbw+lzz*PDzs~!Gbmo1G9?5op42$^{*m)b+G3TLE_;6=DJAu zV!~IN^i*T&0UmMa*(SNy8A#~QpqKM*SmF>Q63G!aY zcte%umNuZD5XJwuT!6d+wpjsFs$hnmh7w<-JP%lNyrhFk+)}1BiUU4 z#Z!wDBvU85XmqL{ONPXtMy_BXDsr#bvxT)JYGftr)&W?%G6XkfI82YItz-%(l|0=U zn{3x2NIK?)&yb-3Mqtkpc~wgx-MrL46EXo9dJLHTH3?FEXiIU-Y>Li#H}8`D%tMHX zl#~Tf6KR}Ke~G5`$85UXlCq{qNOKR8HK|Hj+gPq1mr^Apguy}^Lg=4jY_Cdj0&r|O z0K*CG6llgClH}i@SxKviSRnCfs&>^V3>Cx0%udke&6X9kp1$J{UcM< zL62Mb6$Jd9P)a|ku}NA6QAykZkP4V{D;D%R|FBh4%$`Q(on>%+jr|vDu_m1E}tYh@Tgfpib5AsPWzf<$TiPRWONcs8f^ znQ|wFY5lPJZGC*hX5tD%)|oP-&6N6HX$_Wkdomt6BWy&HQd1PEJQF^q$4LXh6nNNdFLg7vH-GzK!fS@g0}XFZ zlK?IS{cq0(0Udt7p2rXd$ZKBzJ#%h?Zs)O)fBlqBGE$XVKur}N)Hr#rD`!ZpzNK zUdOIIQNV)=PvFbAS+E~-nU^CLqGEIH&w_-)uL!0;hi8@&&Xn*Mbmc;*AS!TKt3R~r zQv!Q#zMP0r!B0#|nxGx^xW$$J4@Kqwcp*@<9kljy7I4KGU#*Kb_1mZb3EV$@*9czR z-0U{J3XywNkAC%`NmyXS5~3Z-SZA-h3~a$_A<@o5rG z8D{3^>p;(f>>wsOj+VupYu%-=?d5;@oDw+B^J4aewmF6&t%hix{9hhUm6}X|gPmI1 zK8NanFsMCF<7i&DWimg4OR;3KkYR}^bU?8&08yr)_PvH9g@Pjf9pjf0Z*qny%$ZK= z>@2DWx0{5FOijxftsjW+LH9;mU3j20hgms$FspQtzJ-KMlhumg_^+s6vp2>yIGRYmT~OH6#C8EH+@SwcKX4Oh+|xSAyE5 zo&CG8O;L|=zjZ~bV)4KI@-=X-Uxdv|CcB;TMyBZv>;LzBJbU_oy%17G#|UC{g!PN? z<$hL7hY~|V5zCTko#E3=(V8}%%x_N>a#=uA^~EEItQObrJF2r*`b!ZjaA3AoGDvTG z8XbL=#UKyZ&oaxM*y70ZRsARuPl59}#Mf+*VDk*k-n`I%uMI6h3x`De^8V$pnbSFjrQ%5z;JcR#_PsScgb) zF+@7CL7DOk;y`L~N_f~3Yo-T%oeXVuky9Bp;J|yyz!smCPq(xGv6btGKYgjfEX}Iv z#3(-n)X`prrEd8LV*gNAux}1$0LpI_1@P$&>fP!r65-7CbEMPtypPr3|cI z`jD>!kZ~9y#sEQ}PB=CKv29Xe0iOq6z!$b%OY`WY=E!KK{`GK^wJgzxm4z+>Bhuc| z36>&aV=TnR%sux!ZtBH5A-k5d<&3(fA0x8XckT~w+hsp$Hf$d6yw=t}(u3ddGy_VTbLA=g(r*G*lZhEM4J-hWxIRoLA z$7>W*nhOM=y*i+O9^Qk5D7+6IWbr{A*^tcnwS^b(-~H>FxT%o|Fc+C!zfX#1bAcP1 zelrq?*D-aHk&}^;^E8aDYMhmIgv4`F1!^jzW(B{bY_HB%CMS$EzrPvuQ=GkpMC}Y^ zcKMh?|C>hs^f`41z^sK9?}W0{N+_7mhK|XxqR|%w6N|N!O={r<5u_<{bA-t7G?10! zcVhd*&3{S;k?WwP)RJD}0M6%{>-IfUZ(+60CDS7TOH=9i*RTOZ1VU~WrXm8IcS{`a z%kd;hb88W7Q+CzYty`2C{kJ#JJKx>&a(|w^Y5%$5SNXd?_5i-gJ`6lJoVuOWK1rG- zTmuW28B?PmMfD`{&cvqqB!q+gKwb1Hnv5Yo(BtHgPYE1F*umq+2hh}#YymaCVj;7g z*#p=U#WKn557=AYocNY??ow3h@$!lHxuTpWHYwS|SK8At!bcN-hE#++Hbfd_LU@fg zQVtI*7A5rc$?`Vp?s1W?t)<5v*DpW$lb-`gkICDgjkv}ZO=h2tHnsh7yl%NT((ob% zIaSJ;R3uWx_d!dHv%yRY)T>rp@tVb@b#@nPiqjjVi%#ih8D=<`P;fyZfRGU=Vmyp zr~QUe4SA!Mj$_nCqRB4Pw0haFI#i}i%Q1K}j_HLGY6U7_q!9iC@~Ouc zRLi$LYMHZaR_|MJ-$Ie^p^_e#_kdVxg+JCdb5m8?`j2znxig?0sqL0?T()-_0T!={ zuu8-%@18a{4a*L&=hK(HjuE1PaUF++Kg2%4#Gg>*5)3|xzG%h#PJo~zM5h!*M%&kz zI!S@vIHyA=8_B?kYq{geJv&rWMaRTp2@?%=CdpIDOBL0WxI*iQ|s%Oua~|lkdYOYx5R4U5|iIOQ=|U#eEQ;LG!<;2pzB;JLw9HK zennJDOMRpR4;z-l-H`v=LI_oZEt(j<@DKcVS)|`vy=96;U_75KA{KJr2*@2z8Bsh zTi;u>5J;>%n@9L$zV(vCu-=a*Z0AWo{8jH1uKtGL%30U5yVyo)Pqfs1_I_G!1*2UA z^b3Yaf9U?N-ir>bL_NYZ3{?Qq`C@CALgkC|-Zf^G;1oh8Qv5M#x{a0)bCC??4V?S~ zpPm!lOS4kTN<(y(A| z)({I615nG#IJv1xZIr!sW#~|~y0)8S9^nOPvMy?$eIjjyJfqmOM{j4-;^XZ}{ObUcqtpOfeq{57d6* zGcS-m+}4U_#MnK^k$wsf4;%`^(=VzdsZZ#r0zi?prdHC>)YP=uP56O!txKFDiyP2- zlQ*vmv4R2EK`NT`s}vI+=GBJ?hS)CzM+yc{Dc{K)t(@pgrmaBPOrLdDUilgRfw=6I zqsDIk{_f~({ygYgUEWh|-%sYWa*m&2 zfFO3TM;<6B*Zq!-!P_E@fsBTaEL1LpMgj z_tHK|;eg&JbYOJPk^aomVE5Ae!`P1(Yr_5{6C6AiLVu>kDi+g2+TpeLqgk9|xL{eh za)hj4QTg&k0$aA}N+7JGyB_f)?&z!5F5( zK=KnZ4TyS;h3qrg;_}ecs^+1Ogx*E5*B-{RyQ<1qLyHl|+)kWbhKDA-^jAGwbA4AI zd+PI527+J$20oGLFSV=}p9#2UHj&1px9t0pm+Fzlz=Bo9rzo7-eyqkX*HbN zcf58oUj|S9-3(}Z(w*B#f42Vb-~Z!nTT;LJ%6*8@v+l~5HJD~Dr)AmjWTJ@{lao}s zf3kV%^<*1_;HNM{_=vKxx`6bn7^#A@VcBbCFHN_qXtcBvoDVZ~G32uPfCQjkL?QfK zLZm;UX>Ii@XA{D}8QX)>C_7H4goq5?0<$DL%9O_UF}7v3Ber!s(_xkqxt7;Tn{$I| zl6Gxf9CZr(Hgva~>Xi+-j+NFJlO$?(Yoj}=!VY}-@gusQU9vNGS*6v@X~i3R^oiX( zz(7lFI9Z6_S~Kfg=u*)M9@cae;RW)bUFEW#+WQ_&*4?2tkGL3dPjHDDP0U=A^A`R*BAxBo;9%phknV56U*^E!K z)1M<@7z*2Gan+65()Y*MrkXP(Y!IgUW55dWbQJ^Vm3lxs%Tt9_HFw&93YUC z($x3GVUnPGGp4fD_uAu8Wn`z0IG9;-n2|IizRF(oJGFBw0T*nvf1|$^|GVBl0TJyA zQZ%Um92ViO(QL5TO!1zF2eZ@a)KV&* zGPc5nq$M91<|F}JkBE;)-3!Nu7^t}sL6x8D2h3L`)KO_dNATR`r%;G-`F5BxX8?QLgS9{-Y4$7Z5!ry54&t z1_I5%?S6DiB(lKLAQfUW_2Q~3C|Fpenxt-Q__79VI*`j{jbgPj6CR*OecvNRbJ-02 z15*snhYXliyFlbI^s!M^OvU&uVB~I4tjNyT;7;P7k!62$kSqB~1|HsU2)a%Zb@1hc z{5UfTlOjKIp9D$onLP35Gv2agv^wL*u)W`L^nCo0?tezQF4snvf-WpZtl)p3Bu}Mi z5TA=9-9_J@dZ&X+Y-C}kLbaW$!euHR#SJ#;KW)j9;Bv9e7byO(9?lB9;Tuc#Q1S_u zoKM0q!t3sSo_KO^R*?vR!Z3&dl8UvMtfUm#LX#vZ!UY=l7xg^|{)J{?HXq05EEc>~ zXQmjqoaq0YS6eGoURjZ!F3_ztkdjRmKZ6V`xwQjnSB7#gN4VDRaNN0cw5#8%-YX?X z5GeMW@TsiFH<$d#c*)y&c`SRDzD3A14F=~U1XQ{?!^RMghCdh#8#m&Q8q@4*dj^W& z7#0_ktVl%3pX+GOrds&6Q(tk@3$(hOE%qKycelll z)t*FRdnCV@Eln5_2UT=1uEe?E{H987L39r)&}AvkFaxKbEQwXWif>l9YzLjGd7^+l zhJ*<7yLX;RLZ76UsWRKTBC~Ju|OfeuiWE{C9Ny0;*()W`= z^}Oo!*7A(5wG!IIxx)3M4))wwlpQB-nsty8Kb$508&b%N4`(zuUvg`u1gSW-$^N^d@E5z$EeW8O6I?QtstP4)x6reNMil2tQ0-f;_UV08HFU~E!k_m zUz)aNZPh(L_YxsqCWm~{$Lc{l8GH?K3nyLO_?6VO>W%wce5{bj(945O@`ryYrq?VR zT`sJ&?)=I!WT6J2L(G}?4lq4}&RF@m;&Bg`08ql~vzZwJW~VjU`1%su59?vkbR(=c zO-1PE<;FPR@k%EATsW=fACuN?i}L;|8XRt!S9Wug0zPa}9q4tZkiWIUOpQSaw4%&A zoPs&*NP)ovJ%!z_sAQ((1fgIBClcFGgj!fmsT3Yb%Pc2Fh8<=`wMQIYk)vU?+n8kQ z)}v{1$GxB>Kp>*w)Wa4&Uu7Y0`^eGVpM0+7TY`(8ttevgoABYQqdVt+`>#UaIccd9 zDk1`@-+eFo=UTJST6cK4JD$ELq3eHq@_CLz<_8FAKXCWSsnMX$phAfRy92#Uwuv1@ z$W8){P~{mEdjs5?v=@&WQk-pFH?(aM&@cVa!y{w^{CzwIoYy7;5NgYsaYg#C_)2SV zpIGY`u?MZUd`E@Q6eGUEA>IlVj|j*fubdp*|2~_753jeTg3%n2c=ow0Imc$4PGI zD6i`jQ*lL(jKxD&hK^(u2vPD0$Ywf4r0zz#q5ht3d*1(Ejq7 zZm7qx_YC9na<}s>5vI89_0`#a&`#f?d8K)MP8sQ!m`U>4BmOv62c5~bTX93x^40ZC z9Fs9_3YBrQP()hN$^M&fQBQ|j$|>3f1)~-ONKx!ERa}*+a)WiZx64gbVcNnD3gzr? zTjtf^d{QYYV=vz~X18U_6@El`dEmPpQOI&=j|~1K#@!p#shjv(y*UF###EA^`lTLq zmXF7OL@Rmyh!)1{ zr7%eb@@g&s{LI}y!FkSZT(upc|e(fC^2~d4X3^A?Pi=v3d^WIsf_1qZ3f1>vO1D$#SGETfZCot8^*k)rvfZ;L31`$of2x? z_){OfVLPkETH-8Rlcb|Wqb|6W?Mo?&Sepah9mQA4ZjUAT0L*y2#uC3hS4uq+07*mn zq+Y6<4l(j5vV^Zyk&#Qs$peq|Lm#wId(R2uEw<~5L^_%Q{=i_-G>vZk9+w#Ub8-#X zBl2ZEw3KapJexTMYG$*#1~mSX;R~J24fvRX9v-*wp5ryJ*$`ob1Ze!mpM&i+jg{JZ z(J(EOa}eP4(TPZBFAQp3$IB5ij0k6Hy0xjY9Mdtvptl))kvqb8+`XPzF#^dYmgAHZ ztm|mC_LBZ=;?SXfDNkTAczgloK0f*HfZD(JCfaazjyvlxfj#11XiLX`c!n?}a>uvS zf!^av&#`b7`L8(fT>}L2 zmR8r)30dO>xZg^p6$BnOLZa9W-XjC2m%QZ~GGr{|QSp$&@P_|7QBW^7Ua&cxC+$^T z%f#A`nWo9!@vnRhMi2597XDpj9RAl&B1?!N)uAsh0C~z_Y7KhlkFwT%W;;dIYc2T6 z-LeG&tr|5$DY)q9zPGNzLyN?EElCDu$4-ztwz@Wt5g5`1TA^Ji03qqljmDFBMO~38 zihJ90P!RViH1zxI$b|f5d*}GG1b;G6g_1s3l8~B(b0J*E6vZ_^VmWqhXE&B0{EwINua%ccNqX41+q^H@+pId$sPTKcWbB|n-hNj8RVz>azJ?B(j;aLpjzsv zvh;p(4*kOR_+6k!|LVe()zu#jTI)eO>`lWY{+`CNo*wK$1I}4>BdvJ=@~Y?+=E^PY zPiwR&1t_U-kDjzv=w>7f%a~4L!BIOPUfsAFkxErhUC$9YM}#GC)YdlmK^K z7DmN!h8rGvuY+W>VIg z(41p9K9!#ylsKN@7K^`p3NW2@j?6`F{m4kbxdsxFNw+Y&X+0^1S+9$Pig92v1HH@at^~$5%^QmPaa6a4w^Yb4b zB;;mV{Tf1Zp+@%UxGOW&_;U;~HpzDe?j?D>qG_=P5*HQTe|R5<)sZNKyw8mh96`ABs7w?QSrd8IG|-BgK`xBkkwJ zt0!cuSw$8_+$O%r6eNyy{wz_`%o)~HCQDwp`tNy@EO27@l+OzWzK7Dr>R%W3uVlnZ;BpM3W#5!bKwr?08V^ZzZA7CTN+MYqgHp{dZ{t8apSYDJJ1cjCuzJukb)|U) z^kR8s#S1_bMyYh_TLZ@`*DBB)ndpk7~y4*Q(TjJr5S<5^-q z$Q4-_?_T^|Pg5ubvrnx$r|09lWe}<@Q|7(@h$(#`r6R4t(qtHYo;m|H>9g2=Td8?& zLI;nR1JH*1l+R=EA>ex;1RDRTxPJ4mwwk@ITz_2LfEXScD{P|FPYY*E?Lh0lWfXeo zYr`V=G9>_ss$*UFVHV5@ld}h@cD6?DS)(vf&FCjLHTU={u1e#wkK=TJ$daV_a9ZR)8%$d!xRz-s2lPV2JH%n*!lnZqXK}7+%0^4k)t+} zgiKD3(oP`{U4(7EDk-s_yCD6m>`*9yI{Z5RK717M*0NnkL*(~#N!@k!?_#3ABCwFH zQ8C4vI2O(rxTcwQvsypSfCYP%#Je5Pd&zx;lqhLL_M`l3ZeDBNhwe?IP<)`rD@Y*R z^HR{_t5C&Su?;i@vFW&iS)dr(Pfj2?IG=c6Fn2A{IO1%6?IQeDPo@qe^wKOmgisI0 zn8~h0J}gmfTyuEj=U+ea3N=BXwB&ZOU_ifIU6Hwam;58QQ6Rnf+pfFN2pR~ao_}qD z#Yu?_`E?>c#6IazRp2+26|LIHEmfe*pH!$m_nc+1#m1-RIE422e)O?-eFKuRSd35z z#>;%z7kE>PrE;*RfM9OE8gT&j6nU}DW2xHA)X%m`GrZ^4tNx{KP6)@AWkcV!%_&RxY*LI_xu@SWwB!#LY)M+U2r&7AL#0V=oc z9ScoV+fz$jN;TIvR>RTI0ut@EZmub8-Yondr`i+>@_+psqrf?>;oJe(V-i@PGUz!` z^;A;XBr*(Iq)hC)kf8gvs%?AB5*|zjqj>(mK`&tp5i~2GEu0^XF#fmHVLF&Y0L&#c zcD;3i$HuKJ?$%(>6jCQwZYkSOVvSYi7|A978c4(MNy28QJFS$8`+^Xa8gaMdl50}p zchXDj?+z<+BNZ^BQPIUgcVQxe;CxmHo~6d#J%*SBR4mPch|8hb>AXL~NxUXrM3@Vg z_)4K+5~_r|ublCOH74+=Tr#r#9Z`Us);UKSN0rWxJPI5Ea%Rq%Rv=L+e4UDKYrq~& z7f2!y*ciYld!~Ja;eJSG`3dtWp~7Yw*E`o5S2GeWkOUfg{*DC7xHu>ks7jfxWwo*0 z`Ft1K@$qxR56|frExvdnGwAvQNvse(yd2Mv)c5YMN6+DVG#=y4M{i2YFxiCI+~W;e z~G5y=YmjC5O9f}@eq5_Exf^WSSN0#S==Tg);Vdc{xn>ww$53JGD>@Lmr# zZS<$V4u69+RYF+*(rsSXO4NrpMZi544n5Ka1VlM2!n{0X4Ng88vb-Zi5i_#~0t5nt zpfJ(HT0Kub;IyYMmvoF^oE$21infk{qF|B=@h{sh6x5IyTvlB<|INq zE`Pa89)+0sS)qm$iFtzu!|SF2dxJNMrb{PRCIDQFU}%${ER=_14e4FR7*P)SBL67S zB@-aZ4HRR}vFK0DLFW7XAXBjCfkg6$fYJo4PwewiitvX0f{6wmb1nkmaQCZlE7cP$ zCweOGi-t$ZRp$(PBu!z7i{5wsyVhNoru(lvkRv$}wu}_6`?oCRRTmH&W^!b8Q2g^$ zIiF>`3UGxVDlSWHbE7wKi}Em?LAXQ`XpLJ|9TV~hX%$MVU0wIf9@%V{Pj?qBNef6B z63MOq)#q;spJEjjy&S`2k{DOjCi# zEL^|QuPcL=!ylIESKV*x-;aotq67-<2zq1%&DqgcE0{A=GwaU(njjn;)fVRN)Lt}& zWE*8ne_YOmxUi7Lg%KmGLWe<$<0XY9rBIS}53aumgT%3a%#D~68X}<44PtV58LY!H z1ujHJ5RWsR3TPI_`xgA6G7y0N+~?3sxVv}zbr|fq`La8#0qGeMMQ4!gwFspI#ckun zohkr-bC|gUih4c;_e(+d+HA%}_y0B!3v3T3mm9Q>kGIA@-^w339VuxaI7n}!yEu{C zj0p;p`Q$H-!agE0n#!HM{}K8dRo>QE8KX>;PPJ=5+Pl(`hSnrXeUZRp|YR6(OqPYV{bF6LC~$ zmd*PwBQWnvuU37zCoMsc0Aq5=tW#?)l~8*?nd~Lf)nQ1F&!~bb26;A~Cimh~`p(8I zme0XB+fjF63fuXCo6o&cuf?`o21Ln`2-sVN0;NxDD_mOI3-r4Ot0QB)nY@)2cmW$*6j3My%;t5WT1c z{S^F5@mW-u%pl(tKSF$cUPW3aW9p*TK#|rN+HBIf=YpNwV5}nFI6w{mqC%hlD}5{< zF6(fzGNoJ;)lAK(^JW=Qjk~K>CHjB$77z(Oi$SsRl*!prgs!m3d7-l$q%viBW$g`F z^Qz&TgAp3f>3MS{4h!~Y6{#@z6xEE4v>ANL(lKwl=e!@(4Q2Es5*{do_`G3Nu^&J0 z(+poE6oCm%)=UGja@>jBEd^y|WC8v+3Ua z00Ti2TnBTIAL(N023@gkRVBr;O-vWEw~5w!Ga7LAh;8p0D*fUyY=6F>e<@b zFT0=KZ_{1W+`qZ{?&|K-=Um`^Ub~-~vI3awbYmLK(?7{58|}Y6r!D1NY`${Jxa}cu zYzX(em|N4le$?zkErIe%W=;E0P@yRHZ~i2A4Gqr9o&Ar zJzrTB(?}80{A4U3`%0hO$V9unVWz;Xb2YrrNL}^7a^th02WCA`KH9WHkEK#A-w-*V=Ht%pB!?`sTb@UZA zT*6kY9-per;J^K}xLHx!pLZT&q}_sGjPoYFslTs<5l7tp`jtNUV)U4GhyuWm?Kjuy z(^@9K2E;DEDas%Fja}}Pn0PMbX!Lw@&m8>rttYmGh=6;Z&0j(D5*8L_yT*N7aeVy1 zGsd@0)n^joj#8B36OoX(S5~h8S!XCU52r5`{y1d(7WXo^mk#s1ATpq7<16HF2p5+; zVscX1{{Z&la;{BCgq^GAsM~v{7iGO3bGO6SclRuED_Na%MX)%9-OX-zRH$uPJgM+S zt-}0b9SH|6A^u3zJNIaLd={PJgSq6D*7c&?Wtrxt83z+RrUxD`er4f4qcKxtM{`1- zB6kmrtEzo7n*QhPs-wdc7ukKi$auxSvw9XiQgMIk>RKfGVfJ08x17!1&I;@}*7Vs>|7g9WK`gCCh!2@n z-ZxLt&l|j_GUb*0W$F`_PbL;q_`be;;9+BAy{{)S4O(65I*-UM;}Q|qo{&DCOw^9< zq{@acya@il(O&sxabTt~{uM1g<$X;nkrRNEVeUoRVwCGrQ(M?I-W3CnnvY_2n?>L# z&l4k{^L?hxnBJ>IpTGLON~FS{51^mT<40-}<;y0-!4KCs3`~@J`=&1_&Hz-R#rPBE zNT`sm)#mtmJmGY?@S;z8eJhM#mDN^#(wM0jAc@;~oyc>AQ1Uy~`mEbR0fK z$7keJ_*nVw!J&R7jD{hT{5c$L3kD7I3*j0AIF(_p-=ZJ%!J|BlT{ZrI8A};`Zm1vG zV0_)}Q#_MpM}~rNXZ@(q^y6s=mTM5^0CKPma%ghx6ReneI$r7~{jMzBnqq^ycGZ*I zVVi`o_W4cEWKqWz)Esp+tHk@$ACN+n&USgNrG3q~Pa2HjnMva-HlK11d zPRPFds~ahnvj@|!GEDk@{-|APp&-k#~CZHnrdF3z9+nW0a z)-hOQ#Po-IDd7EiQ+&+WxCg*pszzxmfrk?3HA8xiqJNbUlAt*f=-&V1x|PaWfZsnd z0!L_33n$LnX)GI*IudG>pMTLRS60bC;ntBAQvZsIR4UGUG-B6zVawG|hAGPU)q z1npf`G^sV!C#0;LS|5Omwy|F=iVoJV!D96MEESSjSD@Gm?*5Q+>KOH1i=)wL;fUM9h>P(z9ZE2E=6D( z1J~N&QwIh^ktD!j8^@&L-mm4w9~p(mL7iLDF*?BYMPTcn3r8qE@DM_V;BfC*W{}3BPPwkzWA!(Q zdRzQSYwPbegogR+xI$hr(#L3P>IG{K?!v9QiEv0nSc{4EixJ;5l*Ao&rrawDH-z)W z_s%s%o+~NIsK+k@K(oqdi#Gr?wHPH@kjI4TKpyFwvA*`@nC3w(XJ}nXgK@jLdwpEKqUg4D=adV2Pf|~RCJeKpu+;| zGikaDWNXePg<`EtHuB=xd|v9DBja}22}>$@1LgjG;pC_uaPb!yNQ9Tcsg<$NQ~%rg z7m8UZ^feO17ead&wP@yH`=25ZA%mo2vRk&BVQdKZ{9TJhGK6-a)*n$VS;)+@+E+7m z8&EW{q;wB;G;lM>LW zwom2u$63!h*hFuIz0JdU$B@u-Ux|^-gh8O9iH$CtxwaYvIWY zZQn7?3%#qJX{cAF*A4L}F(2&{bYhda24p^dkIY!h3hs2clixV_z1pb;bzcAg-F~-V zf8{r~=*Z+Y7=QDhVU$S5k;V&X+w@SO=@n)q>hF{Cba><-Jc5w=jhMJ7o5;%AV=oz9 z<}^(5Q3`O%smzZ}M^kwU@MaenPtPG_G*TvGNfNOYm0li8yVo;3+(F&}7<$bl_RB>JAkL`Ip@$QTq;(~n^q z3anfFA_e(}w9=DCkUk>VK@#fdSI3jXNewUvC+#f_`n%tVptTT=2f1rN+Bg4vngIaU zt=TxP?JlgsotvBQg48jO37Pn7YfcAAUa&hJvNd&|JXU@glkriW^0Q&h?(Xm71EO@X zOsy{WvKM%s--K4ag*Ye({QQ;`_$U6E4)}hRAnEo=isPN@RsS87h4F)+F7Wt1UDxA1 z!>0)wsa=m&9;mI#<~dOXA?&oD=;8edWcwzZ1wvnTOk-g$_f#)d=_5LXWR zzDi@z!W&0k1yL_b!rQ&E$F$;89hfvaXrA8>_rq~KG<8}@$1x;{GoX#&8w|!xI^v;K z<6bQvz?&s)$8DDT-}a}>v>8*h2ycwwM|i4NmA$sGDlo) zB)dOS@`s+HKKK!#+}e+abqt>-%b=t2GFNT}=W$QyayPE<+#se+Xq_CEWaA?#9+^ey zf6RFG*6KTX3%cLAKLfXZ7xj1+Ng^U}Dl2$caTC+4 zy2Ii&4>)nk?o=6N)>@=>dN*O1g8oS42RD8`ItX3MeMJ(*GIMvHOWsMCiu@)fJ|crKj~9Rb5u! z64@n!kMkIW*GsSj0YO%!Y1hB`htUvUoBEI4aNSOv{wH{~79lNv?(=!%$gh2gjLLL` zWEVs;%-c=5R`B-C2(CihyO>ha)HCmb7SWy%GO@L3J0oO_cQyDS-Q!=zX9FSnYeVS@mAOgv5{`+x_1sv)7F32mMJ;W$lYqANSb{`OVR zVPhI?XI2c{^NKEKx8sOCB^@az<=FwmAsG}LD1Gd{Ma@En>5CLGtMJ{-hhzC0`fLUI z)+(PklA+77WV9I-`fu!Au){HUK!||W|7T$^NJyNYkBv~Kz)qc;Y+OG32;u7kCY2mm z=@60*7tc2X2RQtJ4PO4ezvv>pf{9}2FjlW$O<%!a)x_@+Zy;+hr66!yKAVu0iBAO8JvbE# z)*NUBfE%YPF<-G%D-s;6jk76bBCNv!vU1A_t8(GmScwzZ)?iMtZ_GJ&{ja%Bcjk`J z2Ypuf*M(C}qLM+Qwh?jlO{Gf(w5v;R#TYH$n%oKfT3cxcZfy?&RS^R0)W5M%s zwJ^^KK`FT-qufC=)Z#xxx6&_Aep<8I1crnay;XAz8GLQShT-?X6aJKyr%h`>0;x)o z(dirg(GMIy|A8qL9t*Z3|Lw?6FWfxeyAG(yaD*pp&7n_tCsC=oiA%ixg2P}pICj`1 zT#>*G&Co!T=n*83Y3wLo=u!zwGR~@;j#SftcG8NcpFQFeRr_4`{WdFD4c*gb%;At` zNeUm{$6+i=4ib4qv}Vaqkz0me0`pRtYIxv@g-qZNL3m_);G$aT`hC`k-H}YsoD)-C zr0IWrc|#fu;T(pqH<+vx2Rv5RR0PR=j&j+bTRBvWWu(9GeW^<3SzMIR$gJfvP?~3~ zz{BYjJI!IQ5c)nHa(S0DBV1xNnY=vq-AQ&)`8E4bIKgThQtQh=1~A<`!Nl8P*%F-lFN7e@uIcpCj0C-cka>Wwp^mdW{Hm zb42t!TBjns8wC};q=O!OPJS%){X?>yhUVAYp>wd48_?SaSU5iA1s`m5i zJVu}XdfPj_wFs@(gYTOcDyoNCrxq-UsRcQ1NsFVs&~&$Bp#_6rF5c-vgAq7T? ztHU^A3kUVMfuy$i_{jQU*CP;JyuO00ZOVpiZn8*CiT8tn<2_G11XC*YWD>5cssWTJ z_OXx9VvhlL)f2Tfcm9u}nVkBl1@VZSQQE6Zpde7361=8~;?J(AWfku(uMm!*n*5r+ zQiGKRYN$DDVsxDR#w9y*h``%g0?Db~i-GePb)BW@;^gh=KNgtX%aA~bOtNlyNAThD z!L7)Q=5(f|QjGG}b8SgWTs%{6;O2p6oZ?z)+PE9+3PP^Rry1jy$( zqw)~BRVcb|F3bo@^Qc^AXd0Ev1Ox4;TuG^PaZ%4M8j1|F*itEl<=>=N-c*EW>%S`R zf16nSaWPsj>W!wDZz2{I`D zbBPfq;WI9HBB#X8#HXi<9$HjIuohKAwsT$Qv~y+@s8evE{{Aao^w)T)6nP>;^vx?Z+fl^*GbK!{OI2AEwI z2QSLMX}VFHWCj&-dicdDc^|}Gm{Uu6gn_$7bDBZ%pwBs#WNNCo9x3Q3S27~V;c`_1 zVZPtgz{k2|SrTT4n^$I_z&GFTFPDw}A&!=~uhiL>m$jIAbmtU5znOUV!PE$dO5MV~ zPQ8ue{MkRX+*=!;N_P$^BMnJiVzd}rYGWhu22Mk4%e?U`Yxr2w?Q2ZU5RqzFX@|Uq^SKzKJn#VG@xqx;@AH2a?)b7)7!NM!)5oAXBI_Uk)#ij4PKLn zaqUP((|l3cf;Ksrr!X9{VpH%6_+{y5bz#vYMDSUiPX91g5UYxOeKs*6;EFBuy%qll~(6BwcO1Ydr}U)uk3D=u>Cz^ zdI$oyTWvV*6>PLBOIq;QT_d+SNc`MS2&+Am735@YE76}V=H>jQcr=fJC+QF%Zpz1e z^gWmy^Yhm#(<%-Aj{YbvRyNTh-?cc_TnDm!`S*e7(JfH-U18L}_f2*X7R5c{&cGrX z%WyZRgx0Wl89s~_R~X}!C08Y*Ia_8rmHql`G99Q)aaFpA)L$V9WW-Q1k?xlNqDzVr z)X~lU8bl*A6z{Qo*p$|UFG9R{;$|t#tLc8`eA8am$b-XbiC!d9Ro`vysnSi;Danj# znORdTlBk?5w)!ge2a#~+z$IWtbG!!l$Doxh3Qxrf1YW zi7o|sYC_18{ED22ilU*^eNWS(Ra7KF$5#@PBb`XWI8W>J9c#47rb*Q}HZ9pa6pf;o zU0&C~O#tB6J0u!zy2SCo+Z#orbVY6U1J~CnBi5Ph?YD7bp3(k1%{7f#<9VV*6MHve zt5!1vDA}aj8aZKq<8?uF6stp21Mn$i$*XG~n*e%H+HZrzmrEMrdFM}fK2PuO>r!J? z7H3^0Q+TFe(#yvWIj8EUeHb1)xiYvDBI8Ry!NNy8%aom+NC-t%uL=itMs6+H=;HgF zLf$Pxpk!RMnEXS+?v+V@Wb}Sc<50=rdFNl%^h$K;h?U?6luO2DW7;6GTp)y88~>gM zKT+cCCjS}tue}-7W>Gpds_0my-aL(fBPNnY8-8_hG~a|38}Q{dbAh123IS=-8W%KF z86I04Q&x2}&(hiW*0zX7)SYOow&?j5ynzd^AVR_L7!u$XUrj;xqlEmfl=YkYnE~wC z_Jao6i6SJCEb(#5_)<~OafyNhRn3Z~&{1&5E18FJ;&%(!G5ZlIkZ{MdOecBd&v-4N#ZNWjLJm_f}oxkOPRjFM4h0KF&orM#+HolJ(E3d zMb>qE^3Fn$l-rMvC9S~3Jq5$ey-^{0PM+}s|0LBS`6pAP=xfW6Vmm8C(FPR-jzz+ep`hr(Gy>HOm@TXCx+6V$FO(CfUfxQE+#J`4Xrk8wU zTuI?4O{98<_!x7g2$sNYt0bAM=z`gM3Jcr(wYuqz9yGz`L}{R$k#uN4qBZzV0AE_H2c6kQ5VDe#N zqx9K{T8TR`ARs0@4SZ{HQr8?Su-%bRZ^ea!s3B~j5Tfcc%4{tv8J6P?3=3~^zG09r zdj0f1{rVuf#Kj^;Q-+WAXLVeIQNdQ<-FQrKcn};t0z>Ja`gS(U;D48!5 zIPUHHB0$PY>_;*M({TLsH{Z_{a*98gabNF=rqY*Q##x$!WUJhP21k0&zA`mvP^?Oc zsO*yokVvjm_hm~!mfKJiaQ`9`de$B>O19FlQpQ3N|6R5?;D=FlNOazmb;TQo$5Tic zpBI`#gL);Si!5QDAlcyT`wBH7^6vatIp8iDDlczl^nEB~s2u0><@SoIB3p-eDDi5* z`iG7Oef%krB0DoYW#-LbUEO>{Y;T|eP`Gt~^3;G{esJn}9`h$uKNaf^`Bd)Tq3$WH zjj5QdY%o`Qb2cwa>+nDpSG$3G4_K+xnXosxz0%JvY>D`YXf2FP8Ss%<$S303HMUwO zJ6RWhTT4t1vBL7fiBtm0pz$eDM`R+#PG8c;kjg;>XSsd_TYFWp>jO_5BxE(7xJxGa zPq~}^zi5UC#MF3@tc_hB>4c0`vZ;IinNk$e*o2sVEK=g)ji=- z`WKa_rCNps29rMu3`cJXnQk-BUA5l950g}k`@eIMVuO()-*Yktnu@uqATw?^r|rMg zom4xKw1?o@L=zJ8uIp+>V2=!fNFPf`C!zcuuUv8kOl6|``vRGC7CcEuiYyV#^`Ch@ zy&)*%${;K>xFn8})U*X3&R`dHst?>3gyg$|*3mN%jQtM!h1x=S`FCKXqo*Gqc~-4z z6<0`)k@p!>3U{>u6PdChZBS%BCvXkElBqYOHP3P+4iD+1A{m+E8(Fk2wz`j9^Y+X& z5xBtb?O24el~1gv9`yOfpLW<~lAMKNI+|dsA>s|(+1m$PT1+k+6w~nqL=t44a$|Np zat#il&!x72a=(!vG#~~*9D@#n2V2KNQjcgJrl5HL2a z#d(*mMF)fBKS?pzJ8KiqbOtIf26cd~c*_XQHsZyF!Y9xA=3#o9`5RjZ4s*cg@A_p$ ziM2Vc+D(d7qLUJ08Tve@y8Aj5!9}1Sh>K+1`1ru^D+$kmhQ9rMwqp)jT1U3AlrJ|k zuAy`WQXkKs4pW-exQQ&%9a9OECN2=7QX;yT6~-PaHmJsIhkcZ_@|yl-r|HfP96jxf zls35us(XYt{mDuEXq%P!aRWVV22EWia6)zSCI4#|s|4zUKJOv36Ai@E*mfRIzUt3h zy5YuT3fB`~h*NOAOVf+sphPQA93R5xF-n({FuGuUVm}0X)GBQ5Eu=TqT6gVoj&CPP zo)Pt>IY|O6G@|cPf=E#;;F5fqQ@DQ^(yUbrY#*d+-8-o1aZJH2-pP3bUG@XsKM2T8 zBYYn@#;+-T>Z|(7JGTRt?|lQ6|C_%W0AVBU6Bq(-#)^N>A~`72?i%TivmEPojoCRJ z{T@&;VLB9B=cWF_`ptWqd#5eaJYX9(yQjtYhhLprK1a*?b{b)WOxTG6Pn3w(CF<#K zy^EFzy?4Vuu{NK5rrEi8(_i&-oCY}y{~Z!N-Dz3wrs7n+kfQMk!^FaF5iGWa;Kav9 zW~?Rl8U-uxL7yau4QAzC*Gsfwnj;J*9rYBcyLve8?`}=#HNgiW3o|{Gc~wo|=5ii; zT_bnMnT4xop%)X`R>BH78XsZsg%r4seu|I9dtVd=rANc{pPt(T>=HuN>Fa+VR)2Dk z_%&?(a*FE1q$63Dj-mx?Ll>pdr{{uK+J=|pu0`&{lOZw(oO;0v>)sI=1qu&5MG%&f zihZI%js3A!eQTer8fZftt1(iTIxxS8&E4Eu7hhMSL>ccCE$`g)3a zM5LQBF(&izf{jI1T$OBz4K5BTui!D!iffDtxzNNEW;_%oI61D;lbz6|lwz|0G4mG^ ziUVp1;ieryn$B$DrtlDFev=9SQ9=kd7wxkMS|WSfJOc1f$>!#k0ck-f zo{!AFfq{)JP;vjO0YJ{+nv^?JNQoyG04f>~T|ObGi&*K^vY!w zjrRt``yWjk?4RoPe@{T4c;JCU${6$Ry1~yQ66>nc+MKR(OuWZx zp{cKxlEC2{Of~VP0WX8cGd-^nk=V>RRRK)&FU|b&<#VZDMjsW*yZjK<3fN8kW2f3f z-W>J7Gf$RXZ)iN(2UYVrOdgL6ami`AY}@B7YvBhKr6_!sN0y$Y43nWQcJA*pj_)Iw zPU4##N#l7{6>n-2Caf+?M>`b$(Hz<#hd7x%v>SHSY7Ls*BXcUYEVf}PV@bp#^V1*< z!D(kGskgm4Y9akSp2RPxUlPK5rE_ZaF>p&o8xLaD;H#h`VBX61cfMaxuTHRohR{^_ zb`q4!kLj`cuH2;+m9>X;^8atGwm>EA11R%zz7;$*Z#6Q8be=J(lZuUR^X1MCf3zE` zx~L6aFj*tDG|*!@-EPIH+J!ltr!^f$&$jzWQwheB#!UZjEo$Msy~qRk*j4+a|U zV@WGd!EEz9t#Xxu3*FVN>FM^{x+41=a-H9eyW|LK9osXWgm9o*v^ps_VXA0%;3}V$ z+|ryychadUynwGPa}kzZodEV5rb2;$6+0NP*>+@S&+n~r5ufAR^RZL8i=R)Y@LK?|tC;um4Z + + + + + + + + + + + + + + + diff --git a/frontend/modules/timeflow-alarm/android/src/main/assets/alarm_prompt.mp3 b/frontend/modules/timeflow-alarm/android/src/main/assets/alarm_prompt.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..95a3dcd0eea0f0ff663e5ffd1cfe6d4cd254f1a9 GIT binary patch literal 29376 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 Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, pkg) + "overlay" -> Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, pkg) + "fullScreen" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, pkg) + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg) + } + "battery" -> Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, pkg) + else -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg) + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + reactContext.startActivity(intent) + promise.resolve(true) + } catch (error: Exception) { + promise.reject("OPEN_SETTINGS_FAILED", error.message, error) + } + } + + @ReactMethod + fun requestNotificationPermission(promise: Promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + promise.resolve(true) + return + } + if (ContextCompatPermissionGranted(reactContext)) { + promise.resolve(true) + return + } + val activity = reactContext.currentActivity + if (activity !is PermissionAwareActivity) { + promise.reject("NO_ACTIVITY", "PermissionAwareActivity unavailable") + return + } + val listener = PermissionListener { requestCode, _, grantResults -> + if (requestCode != NOTIFICATION_REQUEST_CODE) { + return@PermissionListener false + } + val granted = grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + promise.resolve(granted) + true + } + activity.requestPermissions( + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_REQUEST_CODE, + listener, + ) + } + + companion object { + const val NAME = "TimeflowAlarm" + private const val NOTIFICATION_REQUEST_CODE = 2401 + private var reactContextRef: WeakReference? = null + + @JvmStatic + fun emitAlarmEvent(type: String, scheduleId: String?, alarmId: String?, title: String?) { + val context = reactContextRef?.get() ?: return + if (!context.hasActiveReactInstance()) return + try { + val payload = Arguments.createMap() + payload.putString("type", type) + payload.putString("scheduleId", scheduleId ?: "") + payload.putString("alarmId", alarmId ?: "") + payload.putString("title", title ?: "") + payload.putDouble("atMillis", System.currentTimeMillis().toDouble()) + context + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_NAME, payload) + } catch (_: Exception) { + // 后台响铃时桥接可能已拆除,忽略发送失败。 + } + } + + const val EVENT_NAME = "TimeflowAlarmEvent" + } +} + +private fun ContextCompatPermissionGranted(context: ReactApplicationContext): Boolean { + return androidx.core.content.ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java new file mode 100644 index 00000000..e36870a6 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmNativeBridge.java @@ -0,0 +1,162 @@ +package com.timeflow.alarm; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * 持久化原生 fire/dismiss,供 JS 在进程死后补水 disposition; + * 并在 React 上下文存活时向 AlarmModule 转发事件。 + */ +public final class AlarmNativeBridge { + private AlarmNativeBridge() { + } + + public static final class DispositionRecord { + public final String scheduleId; + public final String alarmId; + public final String state; + public final long updatedAtMillis; + + DispositionRecord(String scheduleId, String alarmId, String state, long updatedAtMillis) { + this.scheduleId = scheduleId; + this.alarmId = alarmId; + this.state = state; + this.updatedAtMillis = updatedAtMillis; + } + } + + public static void notifyFired( + Context context, + String scheduleId, + String alarmId, + String title + ) { + upsertDisposition(context, scheduleId, alarmId, "pending"); + sendLocalEvent(context, AlarmContract.EVENT_FIRED, scheduleId, alarmId, title); + AlarmModule.emitAlarmEvent(AlarmContract.EVENT_FIRED, scheduleId, alarmId, title); + } + + public static void notifyDismissed( + Context context, + String scheduleId, + String alarmId, + String title + ) { + upsertDisposition(context, scheduleId, alarmId, "confirmed"); + sendLocalEvent(context, AlarmContract.EVENT_DISMISSED, scheduleId, alarmId, title); + AlarmModule.emitAlarmEvent(AlarmContract.EVENT_DISMISSED, scheduleId, alarmId, title); + } + + /** 延后:落 snoozed disposition,并由调用方负责重新 schedule。 */ + public static void notifySnoozed( + Context context, + String scheduleId, + String alarmId, + String title + ) { + upsertDisposition(context, scheduleId, alarmId, "snoozed"); + sendLocalEvent(context, AlarmContract.EVENT_SNOOZED, scheduleId, alarmId, title); + AlarmModule.emitAlarmEvent(AlarmContract.EVENT_SNOOZED, scheduleId, alarmId, title); + } + + public static List consumeDispositions(Context context) { + SharedPreferences preferences = + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE); + String serialized = preferences.getString(AlarmContract.DISPOSITIONS_KEY, "[]"); + List records = new ArrayList<>(); + try { + JSONArray array = new JSONArray(serialized); + for (int index = 0; index < array.length(); index++) { + Object value = array.get(index); + if (!(value instanceof JSONObject)) { + continue; + } + JSONObject object = (JSONObject) value; + String scheduleId = object.optString("schedule_id", ""); + String state = object.optString("state", ""); + if (scheduleId.isEmpty() || state.isEmpty()) { + continue; + } + records.add(new DispositionRecord( + scheduleId, + object.optString("alarm_id", ""), + state, + object.optLong("updated_at", System.currentTimeMillis()) + )); + } + } catch (JSONException ignored) { + records.clear(); + } + preferences.edit().putString(AlarmContract.DISPOSITIONS_KEY, "[]").apply(); + return records; + } + + public static void stopRinging(Context context) { + AlarmSoundService.stop(context); + RingActivity.finishIfOpen(); + } + + private static void sendLocalEvent( + Context context, + String type, + String scheduleId, + String alarmId, + String title + ) { + Intent intent = new Intent(AlarmContract.ACTION_ALARM_EVENT) + .setPackage(context.getPackageName()) + .putExtra(AlarmContract.EXTRA_EVENT_TYPE, type) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_TITLE, title); + context.sendBroadcast(intent); + } + + private static void upsertDisposition( + Context context, + String scheduleId, + String alarmId, + String state + ) { + if (scheduleId == null || scheduleId.isEmpty()) { + return; + } + SharedPreferences preferences = + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE); + String serialized = preferences.getString(AlarmContract.DISPOSITIONS_KEY, "[]"); + JSONArray remaining = new JSONArray(); + try { + JSONArray array = new JSONArray(serialized); + for (int index = 0; index < array.length(); index++) { + Object value = array.get(index); + if (!(value instanceof JSONObject)) { + continue; + } + JSONObject object = (JSONObject) value; + if (scheduleId.equals(object.optString("schedule_id", ""))) { + continue; + } + remaining.put(object); + } + JSONObject next = new JSONObject(); + next.put("schedule_id", scheduleId); + next.put("alarm_id", alarmId == null ? "" : alarmId); + next.put("state", state); + next.put("updated_at", System.currentTimeMillis()); + remaining.put(next); + } catch (JSONException ignored) { + return; + } + preferences.edit() + .putString(AlarmContract.DISPOSITIONS_KEY, remaining.toString()) + .apply(); + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt new file mode 100644 index 00000000..8fb12c2f --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmPackage.kt @@ -0,0 +1,18 @@ +package com.timeflow.alarm + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class AlarmPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(AlarmModule(reactContext)) + } + + override fun createViewManagers( + reactContext: ReactApplicationContext + ): List> { + return emptyList() + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java new file mode 100644 index 00000000..81391669 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmReceiver.java @@ -0,0 +1,36 @@ +package com.timeflow.alarm; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +public final class AlarmReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (!AlarmContract.ACTION_FIRE_ALARM.equals(intent.getAction())) { + return; + } + + int requestCode = intent.getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0); + String alarmId = intent.getStringExtra(AlarmContract.EXTRA_ALARM_ID); + String scheduleId = intent.getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID); + String title = intent.getStringExtra(AlarmContract.EXTRA_TITLE); + if (alarmId == null || alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + if (scheduleId == null) { + scheduleId = ""; + } + Intent serviceIntent = new Intent(context, AlarmSoundService.class) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, title); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent); + } else { + context.startService(serviceIntent); + } + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmRingUi.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmRingUi.java new file mode 100644 index 00000000..1076358e --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmRingUi.java @@ -0,0 +1,320 @@ +package com.timeflow.alarm; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.RippleDrawable; +import android.graphics.drawable.StateListDrawable; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.StateSet; +import android.view.Gravity; +import android.view.View; +import android.view.animation.DecelerateInterpolator; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * 提醒打断界面:时钟与标题居中。 + * 颜色与 src/shared/theme/index.ts 对齐。 + */ +final class AlarmRingUi { + private static final int COLOR_BACKGROUND = Color.parseColor("#F4F5F1"); + private static final int COLOR_MINT = Color.parseColor("#DDEFE5"); + private static final int COLOR_DEEP = Color.parseColor("#15352B"); + private static final int COLOR_SUB = Color.parseColor("#6C7972"); + private static final int COLOR_LIME = Color.parseColor("#D7F36A"); + + private static final String FALLBACK_TITLE = "日程提醒"; + private static final long ENTER_MILLIS = 420L; + private static final long ENTER_STAGGER_MILLIS = 60L; + private static final float ENTER_OFFSET_DP = 10f; + + private AlarmRingUi() { + } + + static int topEdgeColor() { + return COLOR_MINT; + } + + static int bottomEdgeColor() { + return COLOR_BACKGROUND; + } + + static FrameLayout build( + Context context, + String scheduleTitle, + View.OnClickListener onSnooze, + View.OnClickListener onConfirm + ) { + float d = context.getResources().getDisplayMetrics().density; + int gutter = Math.round(28 * d); + + TextView clock = text(context, formatClock(), 66, COLOR_DEEP, condensedBold()); + clock.setLetterSpacing(-0.04f); + clock.setFontFeatureSettings("tnum"); + clock.setGravity(Gravity.CENTER); + + TextView date = text(context, formatDate(), 15, COLOR_SUB, regular()); + date.setGravity(Gravity.CENTER); + + RingRoot root = new RingRoot(context, () -> { + clock.setText(formatClock()); + date.setText(formatDate()); + }); + root.setBackground(buildBackground()); + + TextView brand = text(context, "Timeflow", 36, COLOR_DEEP, bold()); + brand.setLetterSpacing(-0.03f); + brand.setGravity(Gravity.CENTER); + FrameLayout.LayoutParams brandParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + brandParams.topMargin = Math.round(48 * d); + root.addView(brand, brandParams); + + TextView moment = text(context, "此刻提醒", 13, COLOR_SUB, medium()); + moment.setGravity(Gravity.CENTER); + FrameLayout.LayoutParams momentParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + momentParams.topMargin = Math.round(92 * d); + root.addView(moment, momentParams); + + LinearLayout center = new LinearLayout(context); + center.setOrientation(LinearLayout.VERTICAL); + center.setGravity(Gravity.CENTER_HORIZONTAL); + FrameLayout.LayoutParams centerParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ); + centerParams.leftMargin = gutter; + centerParams.rightMargin = gutter; + root.addView(center, centerParams); + + center.addView(clock, matchWidth()); + + LinearLayout.LayoutParams dateParams = matchWidth(); + dateParams.topMargin = Math.round(14 * d); + center.addView(date, dateParams); + + View divider = new View(context); + GradientDrawable dividerBg = new GradientDrawable(); + dividerBg.setColor(COLOR_LIME); + dividerBg.setCornerRadius(999 * d); + divider.setBackground(dividerBg); + LinearLayout.LayoutParams dividerParams = new LinearLayout.LayoutParams( + Math.round(40 * d), + Math.round(3 * d) + ); + dividerParams.gravity = Gravity.CENTER_HORIZONTAL; + dividerParams.topMargin = Math.round(26 * d); + center.addView(divider, dividerParams); + + TextView title = text(context, resolveTitle(scheduleTitle), 32, COLOR_DEEP, bold()); + title.setMaxLines(3); + title.setEllipsize(TextUtils.TruncateAt.END); + title.setLineSpacing(0f, 1.25f); + title.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams titleParams = matchWidth(); + titleParams.topMargin = Math.round(20 * d); + center.addView(title, titleParams); + + LinearLayout actions = new LinearLayout(context); + actions.setOrientation(LinearLayout.VERTICAL); + FrameLayout.LayoutParams actionsParams = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ); + actionsParams.leftMargin = gutter; + actionsParams.rightMargin = gutter; + actionsParams.bottomMargin = Math.round(28 * d); + root.addView(actions, actionsParams); + + TextView snooze = buildActionButton( + context, + d, + "延后 10 分钟", + COLOR_MINT, + onSnooze + ); + LinearLayout.LayoutParams snoozeParams = matchWidth(); + snoozeParams.height = Math.round(54 * d); + actions.addView(snooze, snoozeParams); + + TextView confirm = buildActionButton( + context, + d, + "确认", + COLOR_LIME, + onConfirm + ); + LinearLayout.LayoutParams confirmParams = matchWidth(); + confirmParams.height = Math.round(58 * d); + confirmParams.topMargin = Math.round(12 * d); + actions.addView(confirm, confirmParams); + + if (animatorsEnabled(context)) { + View[] sequence = {brand, moment, center, actions}; + root.post(() -> enter(sequence, d)); + } + + return root; + } + + private static TextView buildActionButton( + Context context, + float d, + String label, + int fillColor, + View.OnClickListener onClick + ) { + TextView button = text(context, label, 18, COLOR_DEEP, bold()); + button.setGravity(Gravity.CENTER); + button.setContentDescription(label); + button.setClickable(true); + button.setFocusable(true); + button.setElevation(3 * d); + button.setOnClickListener(onClick); + + StateListDrawable fill = new StateListDrawable(); + GradientDrawable focused = pill(d, Math.round(2 * d), fillColor); + GradientDrawable normal = pill(d, 0, fillColor); + fill.addState(new int[]{android.R.attr.state_focused}, focused); + fill.addState(StateSet.WILD_CARD, normal); + button.setBackground(new RippleDrawable( + ColorStateList.valueOf(Color.argb(38, 21, 53, 43)), + fill, + null + )); + return button; + } + + private static GradientDrawable pill(float d, int strokeWidth, int fillColor) { + GradientDrawable pill = new GradientDrawable(); + pill.setColor(fillColor); + pill.setCornerRadius(20 * d); + if (strokeWidth > 0) { + pill.setStroke(strokeWidth, COLOR_DEEP); + } + return pill; + } + + private static void enter(View[] sequence, float d) { + for (int index = 0; index < sequence.length; index++) { + View view = sequence[index]; + view.setAlpha(0f); + view.setTranslationY(ENTER_OFFSET_DP * d); + view.animate() + .alpha(1f) + .translationY(0f) + .setStartDelay(index * ENTER_STAGGER_MILLIS) + .setDuration(ENTER_MILLIS) + .setInterpolator(new DecelerateInterpolator(1.6f)) + .start(); + } + } + + private static String resolveTitle(String scheduleTitle) { + return scheduleTitle == null || scheduleTitle.isEmpty() ? FALLBACK_TITLE : scheduleTitle; + } + + private static String formatClock() { + return new SimpleDateFormat("HH:mm", Locale.getDefault()).format(new Date()); + } + + private static String formatDate() { + return new SimpleDateFormat("M月d日 EEEE", Locale.CHINA).format(new Date()); + } + + private static TextView text(Context context, String value, float sizeSp, int color, Typeface face) { + TextView view = new TextView(context); + view.setText(value); + view.setTextSize(sizeSp); + view.setTextColor(color); + view.setTypeface(face); + return view; + } + + private static Typeface condensedBold() { + return Typeface.create("sans-serif-condensed", Typeface.BOLD); + } + + private static Typeface medium() { + return Typeface.create("sans-serif-medium", Typeface.NORMAL); + } + + private static Typeface bold() { + return Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD); + } + + private static Typeface regular() { + return Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL); + } + + private static GradientDrawable buildBackground() { + return new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{COLOR_MINT, COLOR_BACKGROUND, COLOR_BACKGROUND, COLOR_BACKGROUND} + ); + } + + private static LinearLayout.LayoutParams matchWidth() { + return new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + } + + private static boolean animatorsEnabled(Context context) { + return Settings.Global.getFloat( + context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f + ) > 0f; + } + + private static final class RingRoot extends FrameLayout { + private static final long TICK_MILLIS = 20_000L; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable onTick; + private final Runnable ticker = new Runnable() { + @Override + public void run() { + onTick.run(); + handler.postDelayed(this, TICK_MILLIS); + } + }; + + RingRoot(Context context, Runnable onTick) { + super(context); + this.onTick = onTick; + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + handler.postDelayed(ticker, TICK_MILLIS); + } + + @Override + protected void onDetachedFromWindow() { + handler.removeCallbacks(ticker); + super.onDetachedFromWindow(); + } + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java new file mode 100644 index 00000000..4a5af820 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java @@ -0,0 +1,341 @@ +package com.timeflow.alarm; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 与 UI 无关的 AlarmManager 调度器。 + * 持久化 UUID / scheduleId / triggerAtMillis / requestCode,供精确取消与 rebuild。 + */ +public final class AlarmScheduler { + private AlarmScheduler() { + } + + public static final class AlarmRecord { + public final String alarmId; + public final String scheduleId; + public final long triggerAtMillis; + public final int requestCode; + public final String title; + public final boolean legacy; + + AlarmRecord( + String alarmId, + String scheduleId, + long triggerAtMillis, + int requestCode, + String title, + boolean legacy + ) { + this.alarmId = alarmId; + this.scheduleId = scheduleId == null ? "" : scheduleId; + this.triggerAtMillis = triggerAtMillis; + this.requestCode = requestCode; + this.title = title; + this.legacy = legacy; + } + } + + public static String schedule( + Context context, + long triggerAtMillis, + String title, + String scheduleId + ) { + if (triggerAtMillis <= System.currentTimeMillis()) { + throw new IllegalArgumentException("trigger_in_past"); + } + + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + throw new IllegalStateException("alarm_manager_unavailable"); + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S + && !alarmManager.canScheduleExactAlarms()) { + throw new SecurityException("exact_alarm_denied"); + } + + if (scheduleId != null && !scheduleId.isEmpty()) { + cancelByScheduleId(context, scheduleId); + } + + List alarms = loadAlarms(context); + String alarmId = UUID.randomUUID().toString(); + AlarmRecord record = new AlarmRecord( + alarmId, + scheduleId == null ? "" : scheduleId, + triggerAtMillis, + nextRequestCode(alarms), + title == null ? "" : title, + false + ); + + PendingIntent operation = buildAlarmBroadcastPendingIntent( + context, + record, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + Intent showIntent = context.getPackageManager() + .getLaunchIntentForPackage(context.getPackageName()); + if (showIntent == null) { + showIntent = new Intent(Intent.ACTION_MAIN) + .setPackage(context.getPackageName()); + } + showIntent.setData(alarmUri(record.alarmId)) + .putExtra(AlarmContract.EXTRA_ALARM_ID, record.alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId) + .putExtra(AlarmContract.EXTRA_TITLE, record.title) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent showPendingIntent = PendingIntent.getActivity( + context, + record.requestCode, + showIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + alarmManager.setAlarmClock( + new AlarmManager.AlarmClockInfo(record.triggerAtMillis, showPendingIntent), + operation + ); + + alarms.add(record); + saveAlarms(context, alarms); + return alarmId; + } + + /** @deprecated 请改用 {@link #schedule(Context, long, String, String)}。 */ + @Deprecated + public static String schedule(Context context, long triggerAtMillis, String title) { + return schedule(context, triggerAtMillis, title, ""); + } + + public static boolean cancel(Context context, String alarmId) { + if (alarmId == null || alarmId.isEmpty()) { + return false; + } + List alarms = loadAlarms(context); + int index = indexById(alarms, alarmId); + if (index < 0) { + return false; + } + + AlarmRecord record = alarms.get(index); + cancelPendingIntent(context, record); + alarms.remove(index); + saveAlarms(context, alarms); + return true; + } + + public static int cancelByScheduleId(Context context, String scheduleId) { + if (scheduleId == null || scheduleId.isEmpty()) { + return 0; + } + List alarms = loadAlarms(context); + List remaining = new ArrayList<>(); + int cancelled = 0; + for (AlarmRecord record : alarms) { + if (scheduleId.equals(record.scheduleId)) { + cancelPendingIntent(context, record); + cancelled += 1; + } else { + remaining.add(record); + } + } + if (cancelled > 0) { + saveAlarms(context, remaining); + } + return cancelled; + } + + public static int cancelAll(Context context) { + List alarms = loadAlarms(context); + for (AlarmRecord record : alarms) { + cancelPendingIntent(context, record); + } + saveAlarms(context, new ArrayList()); + return alarms.size(); + } + + public static List loadAlarms(Context context) { + SharedPreferences preferences = + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE); + String serialized = preferences.getString(AlarmContract.ALARMS_KEY, "[]"); + List alarms = new ArrayList<>(); + try { + JSONArray array = new JSONArray(serialized); + for (int index = 0; index < array.length(); index++) { + Object value = array.get(index); + if (!(value instanceof JSONObject)) { + continue; + } + JSONObject object = (JSONObject) value; + long triggerAt = object.optLong("trigger_at", -1L); + int requestCode = object.optInt("request_code", -1); + if (triggerAt <= 0 || requestCode < 0) { + continue; + } + String alarmId = object.optString("alarm_id", ""); + boolean legacy = object.optBoolean("legacy", alarmId.isEmpty()); + if (alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + alarms.add(new AlarmRecord( + alarmId, + object.optString("schedule_id", ""), + triggerAt, + requestCode, + object.optString("title", ""), + legacy + )); + } + } catch (JSONException ignored) { + alarms.clear(); + } + return alarms; + } + + static void removeAlarmRecord(Context context, String alarmId, int requestCode) { + List alarms = loadAlarms(context); + JSONArray remaining = new JSONArray(); + for (AlarmRecord alarm : alarms) { + boolean match; + if (!alarm.alarmId.isEmpty()) { + match = alarm.alarmId.equals(alarmId); + } else { + match = alarm.requestCode == requestCode; + } + if (match) { + continue; + } + try { + remaining.put(toJson(alarm)); + } catch (JSONException ignored) { + // 忽略单条序列化失败 + } + } + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(AlarmContract.ALARMS_KEY, remaining.toString()) + .apply(); + } + + static Uri alarmUri(String alarmId) { + return Uri.parse( + AlarmContract.ALARM_URI_SCHEME + "://alarm/" + Uri.encode(alarmId) + ); + } + + static String scheduleIdForAlarm(Context context, String alarmId) { + if (alarmId == null || alarmId.isEmpty()) { + return ""; + } + for (AlarmRecord alarm : loadAlarms(context)) { + if (alarmId.equals(alarm.alarmId)) { + return alarm.scheduleId; + } + } + return ""; + } + + private static void cancelPendingIntent(Context context, AlarmRecord record) { + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + return; + } + PendingIntent operation = buildAlarmBroadcastPendingIntent( + context, + record, + PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE + ); + if (operation != null) { + alarmManager.cancel(operation); + operation.cancel(); + } + } + + private static void saveAlarms(Context context, List alarms) { + JSONArray array = new JSONArray(); + for (AlarmRecord alarm : alarms) { + try { + array.put(toJson(alarm)); + } catch (JSONException ignored) { + // 忽略单条序列化失败 + } + } + context.getSharedPreferences(AlarmContract.PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(AlarmContract.ALARMS_KEY, array.toString()) + .apply(); + } + + private static JSONObject toJson(AlarmRecord alarm) throws JSONException { + JSONObject object = new JSONObject(); + object.put("alarm_id", alarm.alarmId); + object.put("schedule_id", alarm.scheduleId); + object.put("trigger_at", alarm.triggerAtMillis); + object.put("request_code", alarm.requestCode); + object.put("title", alarm.title); + object.put("legacy", alarm.legacy); + return object; + } + + private static PendingIntent buildAlarmBroadcastPendingIntent( + Context context, + AlarmRecord record, + int flags + ) { + Intent intent = new Intent(context, AlarmReceiver.class) + .setAction(AlarmContract.ACTION_FIRE_ALARM) + .putExtra(AlarmContract.EXTRA_ALARM_ID, record.alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, record.scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, record.requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, record.title); + if (!record.legacy) { + intent.setData(alarmUri(record.alarmId)); + } + return PendingIntent.getBroadcast(context, record.requestCode, intent, flags); + } + + private static int nextRequestCode(List alarms) { + int requestCode = (int) (System.currentTimeMillis() & 0x7fffffff); + if (requestCode == 0) { + requestCode = 1; + } + while (containsRequestCode(alarms, requestCode)) { + requestCode = requestCode == Integer.MAX_VALUE ? 1 : requestCode + 1; + } + return requestCode; + } + + private static boolean containsRequestCode(List alarms, int requestCode) { + for (AlarmRecord alarm : alarms) { + if (alarm.requestCode == requestCode) { + return true; + } + } + return false; + } + + private static int indexById(List alarms, String alarmId) { + for (int index = 0; index < alarms.size(); index++) { + if (alarms.get(index).alarmId.equals(alarmId)) { + return index; + } + } + return -1; + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java new file mode 100644 index 00000000..af58a492 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmSoundService.java @@ -0,0 +1,382 @@ +package com.timeflow.alarm; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.ActivityOptions; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.graphics.PixelFormat; +import android.media.AudioAttributes; +import android.media.MediaPlayer; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.provider.Settings; +import android.view.Gravity; +import android.view.View; +import android.view.WindowManager; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; + +public final class AlarmSoundService extends Service { + private static final long SPEECH_REPEAT_DELAY_MILLIS = 1_500L; + + private final Handler playbackHandler = new Handler(Looper.getMainLooper()); + private final Runnable replaySpeech = this::replaySpeech; + + private MediaPlayer mediaPlayer; + private boolean destroyed; + private File bundledSpeechFile; + private WindowManager overlayWindowManager; + private View overlayView; + private String alarmId; + private String scheduleId; + private String alarmTitle; + private int requestCode; + private boolean firedNotified; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + requestCode = intent == null + ? 0 + : intent.getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0); + alarmId = intent == null + ? null + : intent.getStringExtra(AlarmContract.EXTRA_ALARM_ID); + scheduleId = intent == null + ? null + : intent.getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID); + alarmTitle = intent == null + ? null + : intent.getStringExtra(AlarmContract.EXTRA_TITLE); + if (alarmId == null || alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + if (scheduleId == null || scheduleId.isEmpty()) { + scheduleId = AlarmScheduler.scheduleIdForAlarm(this, alarmId); + } + if (alarmTitle == null || alarmTitle.isEmpty()) { + alarmTitle = "日程提醒"; + } + + createNotificationChannel(); + Notification notification = buildNotification(alarmId, alarmTitle); + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + requestCode, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ); + } else { + startForeground(requestCode, notification); + } + removeFromSavedAlarms(); + if (!firedNotified) { + firedNotified = true; + AlarmNativeBridge.notifyFired(this, scheduleId, alarmId, alarmTitle); + } + showAlarmOverlay(alarmTitle); + if (mediaPlayer == null) { + startBundledSpeech(); + } + } catch (RuntimeException exception) { + stopSelf(); + } + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + destroyed = true; + playbackHandler.removeCallbacksAndMessages(null); + removeAlarmOverlay(); + releaseMediaPlayer(); + deleteCachedSpeechFile(); + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.cancel(requestCode); + } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + static void stop(Context context) { + context.stopService(new Intent(context, AlarmSoundService.class)); + } + + static void start( + Context context, + String alarmId, + String scheduleId, + int requestCode, + String title + ) { + Intent intent = new Intent(context, AlarmSoundService.class) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, title); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + private Notification buildNotification(String alarmId, String title) { + Intent ringIntent = new Intent(this, RingActivity.class) + .setData(alarmUri(alarmId)) + .putExtra(AlarmContract.EXTRA_ALARM_ID, alarmId) + .putExtra(AlarmContract.EXTRA_SCHEDULE_ID, scheduleId) + .putExtra(AlarmContract.EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmContract.EXTRA_TITLE, title) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_MULTIPLE_TASK + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + PendingIntent fullScreenIntent = PendingIntent.getActivity( + this, + requestCode, + ringIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, + pendingIntentOptions() + ); + return new Notification.Builder(this, AlarmContract.CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentTitle(title) + .setContentText("点击停止提醒") + .setCategory(Notification.CATEGORY_ALARM) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setPriority(Notification.PRIORITY_MAX) + .setOngoing(true) + .setAutoCancel(false) + .setFullScreenIntent(fullScreenIntent, true) + .build(); + } + + private Uri alarmUri(String value) { + return AlarmScheduler.alarmUri(value); + } + + private android.os.Bundle pendingIntentOptions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return null; + } + ActivityOptions options = ActivityOptions.makeBasic(); + options.setPendingIntentCreatorBackgroundActivityStartMode( + backgroundActivityStartMode() + ); + return options.toBundle(); + } + + private int backgroundActivityStartMode() { + if (Build.VERSION.SDK_INT >= 36) { + return ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_ALWAYS; + } + return ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED; + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null || manager.getNotificationChannel(AlarmContract.CHANNEL_ID) != null) { + return; + } + NotificationChannel channel = new NotificationChannel( + AlarmContract.CHANNEL_ID, + "Timeflow", + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("日程闹钟提醒"); + channel.enableVibration(true); + channel.setSound(null, null); + manager.createNotificationChannel(channel); + } + + private void showAlarmOverlay(String title) { + if (overlayView != null + || Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || !Settings.canDrawOverlays(this)) { + return; + } + + overlayWindowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + if (overlayWindowManager == null) { + return; + } + + View content = AlarmRingUi.build( + this, + title, + view -> { + long triggerAt = System.currentTimeMillis() + + AlarmContract.SNOOZE_MINUTES * 60_000L; + try { + AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId); + } catch (RuntimeException ignored) { + // ignore + } + AlarmNativeBridge.notifySnoozed(this, scheduleId, alarmId, alarmTitle); + removeAlarmOverlay(); + RingActivity.finishIfOpen(); + stopSelf(); + }, + view -> { + AlarmNativeBridge.notifyDismissed(this, scheduleId, alarmId, alarmTitle); + removeAlarmOverlay(); + RingActivity.finishIfOpen(); + stopSelf(); + } + ); + int windowType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + : WindowManager.LayoutParams.TYPE_SYSTEM_ALERT; + int windowFlags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_FULLSCREEN; + WindowManager.LayoutParams params = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + windowType, + windowFlags, + PixelFormat.OPAQUE + ); + params.gravity = Gravity.TOP | Gravity.START; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + params.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + content.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ); + + try { + overlayWindowManager.addView(content, params); + overlayView = content; + } catch (RuntimeException exception) { + overlayWindowManager = null; + } + } + + private void removeAlarmOverlay() { + if (overlayWindowManager != null && overlayView != null) { + try { + overlayWindowManager.removeViewImmediate(overlayView); + } catch (RuntimeException ignored) { + // 系统可能已移除悬浮窗。 + } + } + overlayView = null; + overlayWindowManager = null; + } + + private void startBundledSpeech() { + if (destroyed || mediaPlayer != null) { + return; + } + try { + bundledSpeechFile = new File(getCacheDir(), "alarm_prompt_edge.mp3"); + try (InputStream input = getAssets().open("alarm_prompt.mp3"); + FileOutputStream output = new FileOutputStream(bundledSpeechFile, false)) { + byte[] buffer = new byte[8_192]; + int count; + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + } + } + startAudioPlayback(bundledSpeechFile); + } catch (Exception exception) { + releaseMediaPlayer(); + } + } + + private void startAudioPlayback(File audioFile) { + if (destroyed || mediaPlayer != null || audioFile == null || !audioFile.isFile()) { + return; + } + try { + MediaPlayer player = new MediaPlayer(); + player.setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()); + player.setDataSource(audioFile.getAbsolutePath()); + player.setVolume(1.0f, 1.0f); + player.setOnCompletionListener(completed -> + playbackHandler.postDelayed(replaySpeech, SPEECH_REPEAT_DELAY_MILLIS)); + player.setOnErrorListener((failed, what, extra) -> { + releaseMediaPlayer(); + return true; + }); + player.prepare(); + mediaPlayer = player; + player.start(); + } catch (Exception exception) { + releaseMediaPlayer(); + } + } + + private void replaySpeech() { + if (destroyed || mediaPlayer == null) { + return; + } + try { + mediaPlayer.seekTo(0); + mediaPlayer.start(); + } catch (IllegalStateException ignored) { + releaseMediaPlayer(); + } + } + + private void releaseMediaPlayer() { + playbackHandler.removeCallbacks(replaySpeech); + if (mediaPlayer == null) { + return; + } + mediaPlayer.setOnCompletionListener(null); + mediaPlayer.setOnErrorListener(null); + try { + mediaPlayer.stop(); + } catch (IllegalStateException ignored) { + // 播放器可能已结束或失败。 + } + mediaPlayer.release(); + mediaPlayer = null; + } + + private void deleteCachedSpeechFile() { + if (bundledSpeechFile != null) { + bundledSpeechFile.delete(); + } + } + + private void removeFromSavedAlarms() { + AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode); + } + +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java new file mode 100644 index 00000000..fcdab9c9 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/DayRulerView.java @@ -0,0 +1,157 @@ +package com.timeflow.alarm; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.provider.Settings; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; + +import java.util.Calendar; + +/** + * 以「今天」为刻度尺:每小时一刻度,每六小时加长刻度, + * 青柠指针落在响铃那一分钟。已过去的刻度变淡,未到的刻度更实。 + * + * 这是提醒屏上唯一的结构装置,也是唯一标出「这次打断落在一天何处」的元素。 + */ +final class DayRulerView extends View { + private static final int HOURS_PER_DAY = 24; + private static final int HOURS_PER_QUARTER = 6; + private static final long BREATH_MILLIS = 1_700L; + private static final int ALPHA_AHEAD = 56; + private static final int ALPHA_SPENT = 23; + private static final float HALO_ALPHA_TIGHT = 0.30f; + private static final float HALO_ALPHA_WIDE = 0.08f; + private static final float BREATH_AT_REST = 0.4f; + + private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint haloPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final float hourTickLength; + private final float quarterTickLength; + private final float tickThickness; + private final float needleThickness; + private final float haloTightHeight; + private final float haloWideHeight; + private final float haloRadius; + private final boolean breathing; + + private ValueAnimator breathAnimator; + private float breath; + private float dayFraction; + + DayRulerView(Context context, int tickColor, int needleColor) { + super(context); + float density = context.getResources().getDisplayMetrics().density; + hourTickLength = 9 * density; + quarterTickLength = 17 * density; + tickThickness = 1.5f * density; + needleThickness = 3 * density; + haloTightHeight = 8 * density; + haloWideHeight = 16 * density; + haloRadius = 8 * density; + + tickPaint.setColor(tickColor); + needlePaint.setColor(needleColor); + haloPaint.setColor(needleColor); + + breathing = animatorsEnabled(context); + breath = breathing ? 0f : BREATH_AT_REST; + syncToClock(); + } + + /** 重新读取墙上时钟,使长响过程中指针仍与当前时刻同步。 */ + void syncToClock() { + Calendar now = Calendar.getInstance(); + int minutesIntoDay = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE); + dayFraction = minutesIntoDay / (float) (HOURS_PER_DAY * 60); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + float height = getHeight(); + float width = getWidth(); + if (height <= 0 || width <= 0) { + return; + } + + float needleY = clampToTrack(height * dayFraction, height, needleThickness); + for (int hour = 0; hour <= HOURS_PER_DAY; hour++) { + float y = clampToTrack(height * hour / HOURS_PER_DAY, height, tickThickness); + float length = hour % HOURS_PER_QUARTER == 0 ? quarterTickLength : hourTickLength; + tickPaint.setAlpha(y < needleY ? ALPHA_SPENT : ALPHA_AHEAD); + drawBar(canvas, y, length, tickThickness, tickThickness, tickPaint); + } + + float haloHeight = haloTightHeight + (haloWideHeight - haloTightHeight) * breath; + float haloAlpha = HALO_ALPHA_TIGHT + (HALO_ALPHA_WIDE - HALO_ALPHA_TIGHT) * breath; + haloPaint.setAlpha(Math.round(haloAlpha * 255f)); + drawBar(canvas, needleY, width, haloHeight, haloRadius, haloPaint); + drawBar(canvas, needleY, width, needleThickness, needleThickness, needlePaint); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!breathing || breathAnimator != null) { + return; + } + breathAnimator = ValueAnimator.ofFloat(0f, 1f); + breathAnimator.setDuration(BREATH_MILLIS); + breathAnimator.setRepeatMode(ValueAnimator.REVERSE); + breathAnimator.setRepeatCount(ValueAnimator.INFINITE); + breathAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); + breathAnimator.addUpdateListener(animator -> { + breath = (float) animator.getAnimatedValue(); + invalidate(); + }); + breathAnimator.start(); + } + + @Override + protected void onDetachedFromWindow() { + if (breathAnimator != null) { + breathAnimator.cancel(); + breathAnimator = null; + } + super.onDetachedFromWindow(); + } + + private static void drawBar( + Canvas canvas, + float centerY, + float length, + float thickness, + float radius, + Paint paint + ) { + canvas.drawRoundRect( + 0f, + centerY - thickness / 2f, + length, + centerY + thickness / 2f, + radius, + radius, + paint + ); + } + + /** 保证一天的首末刻度仍完整落在列内。 */ + private static float clampToTrack(float y, float height, float thickness) { + float inset = thickness / 2f; + return Math.min(Math.max(y, inset), height - inset); + } + + private static boolean animatorsEnabled(Context context) { + return Settings.Global.getFloat( + context.getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f + ) > 0f; + } +} diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java new file mode 100644 index 00000000..fdaace80 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/RingActivity.java @@ -0,0 +1,212 @@ +package com.timeflow.alarm; + +import android.app.AlarmManager; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.View; +import android.view.Window; +import android.view.WindowInsetsController; +import android.view.WindowManager; + +import java.lang.ref.WeakReference; + +public final class RingActivity extends Activity { + private static WeakReference currentActivity = new WeakReference<>(null); + + private String alarmId; + private String scheduleId; + private String alarmTitle; + private int requestCode; + private boolean dismissNotified; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + currentActivity = new WeakReference<>(this); + requestCode = getIntent().getIntExtra(AlarmContract.EXTRA_REQUEST_CODE, 0); + alarmId = getIntent().getStringExtra(AlarmContract.EXTRA_ALARM_ID); + scheduleId = getIntent().getStringExtra(AlarmContract.EXTRA_SCHEDULE_ID); + alarmTitle = getIntent().getStringExtra(AlarmContract.EXTRA_TITLE); + if (alarmId == null || alarmId.isEmpty()) { + alarmId = "legacy-" + requestCode; + } + if (scheduleId == null || scheduleId.isEmpty()) { + scheduleId = AlarmScheduler.scheduleIdForAlarm(this, alarmId); + } + if (alarmTitle == null || alarmTitle.isEmpty()) { + alarmTitle = "日程提醒"; + } + makeVisibleOverLockScreen(); + matchSystemBarsToReminder(); + setContentView(buildContentView()); + removeFromSavedAlarms(); + AlarmSoundService.start( + this, + alarmId, + scheduleId, + requestCode, + alarmTitle + ); + } + + @Override + protected void onDestroy() { + if (currentActivity.get() == this) { + currentActivity.clear(); + } + super.onDestroy(); + } + + static void finishIfOpen() { + RingActivity activity = currentActivity.get(); + if (activity != null && !activity.isFinishing()) { + activity.finishAndRemoveTask(); + } + } + + private View buildContentView() { + return AlarmRingUi.build( + this, + alarmTitle, + view -> snoozeAndClose(), + view -> confirmAndClose() + ); + } + + private void makeVisibleOverLockScreen() { + Window window = getWindow(); + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true); + setTurnScreenOn(true); + } + } + + /** + * 提醒界面偏浅色,系统栏需使用深色图标; + * 否则深色模式下浅底上会出现白色图标。 + */ + private void matchSystemBarsToReminder() { + Window window = getWindow(); + window.setStatusBarColor(AlarmRingUi.topEdgeColor()); + window.setNavigationBarColor(AlarmRingUi.bottomEdgeColor()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + controller.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + | WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + ); + } + return; + } + window.getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + ); + } + + private void confirmAndClose() { + if (!dismissNotified) { + dismissNotified = true; + AlarmNativeBridge.notifyDismissed(this, scheduleId, alarmId, alarmTitle); + } + AlarmSoundService.stop(this); + cancelNotification(); + cancelAlarmPendingIntent(); + finishAndRemoveTask(); + } + + private void snoozeAndClose() { + if (!dismissNotified) { + dismissNotified = true; + long triggerAt = System.currentTimeMillis() + + AlarmContract.SNOOZE_MINUTES * 60_000L; + try { + AlarmScheduler.schedule(this, triggerAt, alarmTitle, scheduleId); + } catch (RuntimeException ignored) { + // 尽力重新挂闹钟;即使失败也通知 JS 落 snooze 状态。 + } + AlarmNativeBridge.notifySnoozed(this, scheduleId, alarmId, alarmTitle); + } + AlarmSoundService.stop(this); + cancelNotification(); + cancelAlarmPendingIntent(); + finishAndRemoveTask(); + } + + private void cancelNotification() { + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.cancel(requestCode); + } + } + + private void cancelAlarmPendingIntent() { + AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + return; + } + + Intent activityIntent = new Intent(this, RingActivity.class) + .setData(alarmUri(alarmId)); + PendingIntent activityPendingIntent = PendingIntent.getActivity( + this, + requestCode, + activityIntent, + PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE + ); + Intent broadcastIntent = new Intent(this, AlarmReceiver.class) + .setAction(AlarmContract.ACTION_FIRE_ALARM); + if (!isLegacyAlarm()) { + broadcastIntent.setData(alarmUri(alarmId)); + } + PendingIntent broadcastPendingIntent = PendingIntent.getBroadcast( + this, + requestCode, + broadcastIntent, + PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE + ); + if (activityPendingIntent != null) { + alarmManager.cancel(activityPendingIntent); + activityPendingIntent.cancel(); + } + if (broadcastPendingIntent != null) { + alarmManager.cancel(broadcastPendingIntent); + broadcastPendingIntent.cancel(); + } + } + + private Uri alarmUri(String value) { + return AlarmScheduler.alarmUri(value); + } + + private boolean isLegacyAlarm() { + return alarmId != null && alarmId.startsWith("legacy-"); + } + + private void removeFromSavedAlarms() { + AlarmScheduler.removeAlarmRecord(this, alarmId, requestCode); + } + + + @Override + public void onBackPressed() { + confirmAndClose(); + } + +} diff --git a/frontend/modules/timeflow-alarm/index.js b/frontend/modules/timeflow-alarm/index.js new file mode 100644 index 00000000..37e2f90e --- /dev/null +++ b/frontend/modules/timeflow-alarm/index.js @@ -0,0 +1,3 @@ +// 原生模块经 React Native autolinking(AlarmPackage)接入。 +// JS 侧通过 NativeModules.TimeflowAlarm 调用。 +module.exports = {}; diff --git a/frontend/modules/timeflow-alarm/package.json b/frontend/modules/timeflow-alarm/package.json new file mode 100644 index 00000000..92afe2da --- /dev/null +++ b/frontend/modules/timeflow-alarm/package.json @@ -0,0 +1,12 @@ +{ + "name": "timeflow-alarm", + "version": "1.0.0", + "description": "Native Android exact-alarm bridge for Timeflow", + "main": "index.js", + "license": "UNLICENSED", + "private": true, + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} diff --git a/frontend/modules/timeflow-alarm/react-native.config.js b/frontend/modules/timeflow-alarm/react-native.config.js new file mode 100644 index 00000000..60b6f8ee --- /dev/null +++ b/frontend/modules/timeflow-alarm/react-native.config.js @@ -0,0 +1,12 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.timeflow.alarm.AlarmPackage;', + packageInstance: 'new AlarmPackage()', + }, + ios: null, + }, + }, +}; diff --git a/frontend/modules/timeflow-baidu-location/.gitignore b/frontend/modules/timeflow-baidu-location/.gitignore new file mode 100644 index 00000000..a4a00d77 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/.gitignore @@ -0,0 +1,3 @@ +android/build/ +android/.gradle/ +*.iml diff --git a/frontend/modules/timeflow-baidu-location/README.md b/frontend/modules/timeflow-baidu-location/README.md new file mode 100644 index 00000000..7683c6d3 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/README.md @@ -0,0 +1,16 @@ +# timeflow-baidu-location + +Android 百度定位桥接(`LocationClient` 连续定位),**不使用 Google Geofencing**。 + +- 原生模块名:`TimeflowBaiduLocation` +- 事件:`TimeflowBaiduLocation`(latitude / longitude / accuracy / observedAt) +- 坐标系:`gcj02` +- AK 通过 Expo 插件写入 `com.baidu.lbsapi.API_KEY` + +## 控制台要求 + +Android AK 必须与包名 **`com.timeflow`** + 签名证书 **SHA1** 绑定,否则定位会失败(常见 locType 鉴权错误)。 + +## 应用侧 + +`NativeLocationMonitor` 订阅连续定位,用 Haversine 判断进出圈。 diff --git a/frontend/modules/timeflow-baidu-location/android/build.gradle b/frontend/modules/timeflow-baidu-location/android/build.gradle new file mode 100644 index 00000000..c330f520 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/build.gradle @@ -0,0 +1,39 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +def getExtOrDefault(name, defaultValue) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : defaultValue +} + +android { + namespace "com.timeflow.baidulocation" + + compileSdkVersion getExtOrDefault('compileSdkVersion', 35) + + defaultConfig { + minSdkVersion getExtOrDefault('minSdkVersion', 24) + targetSdkVersion getExtOrDefault('targetSdkVersion', 35) + } + + sourceSets { + main { + java.srcDirs = ['src/main/java'] + } + } + + lintOptions { + abortOnError false + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation 'com.facebook.react:react-android' + implementation 'androidx.core:core-ktx:1.13.1' + // 仅定位 SDK,不引入 Google Geofencing。 + implementation 'com.baidu.lbsyun:BaiduMapSDK_Location:9.6.4' +} diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml b/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..51e7a1b6 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt new file mode 100644 index 00000000..dd732dd7 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationModule.kt @@ -0,0 +1,191 @@ +package com.timeflow.baidulocation + +import android.util.Log +import com.baidu.location.BDAbstractLocationListener +import com.baidu.location.BDLocation +import com.baidu.location.LocationClient +import com.baidu.location.LocationClientOption +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * 百度连续定位桥:不使用 Google Geofencing。 + * 坐标系默认 gcj02,便于与国内常见选点坐标对齐后做 Haversine。 + */ +class BaiduLocationModule( + private val reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + + private var client: LocationClient? = null + private var updating = false + private var lastLocation: WritableMap? = null + + private val isoFormat = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private val listener = + object : BDAbstractLocationListener() { + override fun onReceiveLocation(location: BDLocation?) { + if (location == null) return + if (!isUsableLocation(location)) { + Log.w(NAME, "ignore locType=${location.locType}") + return + } + + val payload = Arguments.createMap() + payload.putDouble("latitude", location.latitude) + payload.putDouble("longitude", location.longitude) + payload.putDouble( + "accuracy", + if (location.radius > 0) location.radius.toDouble() else 0.0, + ) + payload.putString("observedAt", isoFormat.format(Date())) + payload.putInt("locType", location.locType) + lastLocation = copyMap(payload) + + if (reactContext.hasActiveReactInstance()) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(EVENT_LOCATION, payload) + } + } + } + + override fun getName(): String = NAME + + @ReactMethod + fun setAgreePrivacy(agree: Boolean, promise: Promise) { + try { + LocationClient.setAgreePrivacy(agree) + promise.resolve(true) + } catch (error: Exception) { + promise.reject("PRIVACY_FAILED", error.message, error) + } + } + + @ReactMethod + fun init(ak: String?, promise: Promise) { + try { + LocationClient.setAgreePrivacy(true) + if (client == null) { + client = LocationClient(reactContext.applicationContext) + client?.registerLocationListener(listener) + } + if (!ak.isNullOrBlank()) { + Log.i(NAME, "init akLength=${ak.length}") + } + promise.resolve(true) + } catch (error: Exception) { + promise.reject("INIT_FAILED", error.message, error) + } + } + + @ReactMethod + fun startUpdating(intervalMs: Double, promise: Promise) { + try { + LocationClient.setAgreePrivacy(true) + val locationClient = + client ?: LocationClient(reactContext.applicationContext).also { + it.registerLocationListener(listener) + client = it + } + + val span = intervalMs.toInt().coerceAtLeast(1000) + val option = LocationClientOption() + option.locationMode = LocationClientOption.LocationMode.Hight_Accuracy + option.setCoorType("gcj02") + option.setScanSpan(span) + option.isOpenGps = true + option.setIsNeedAddress(false) + option.setNeedNewVersionRgc(false) + locationClient.locOption = option + + if (!updating) { + locationClient.start() + updating = true + } else { + locationClient.restart() + } + promise.resolve(true) + } catch (error: Exception) { + promise.reject("START_FAILED", error.message, error) + } + } + + @ReactMethod + fun stopUpdating(promise: Promise) { + try { + client?.stop() + updating = false + promise.resolve(true) + } catch (error: Exception) { + promise.reject("STOP_FAILED", error.message, error) + } + } + + @ReactMethod + fun getCurrentPosition(promise: Promise) { + val cached = lastLocation + if (cached != null) { + promise.resolve(copyMap(cached)) + return + } + promise.resolve(null) + } + + @ReactMethod + fun addListener(eventName: String?) { + // RN event emitter bookkeeping. + } + + @ReactMethod + fun removeListeners(count: Double) { + // RN event emitter bookkeeping. + } + + override fun invalidate() { + try { + client?.unRegisterLocationListener(listener) + client?.stop() + } catch (_: Exception) { + } + client = null + updating = false + super.invalidate() + } + + companion object { + const val NAME = "TimeflowBaiduLocation" + const val EVENT_LOCATION = "TimeflowBaiduLocation" + + private fun isUsableLocation(location: BDLocation): Boolean { + if (location.latitude == 0.0 && location.longitude == 0.0) return false + // 常见成功:61 GPS、161 网络、66 离线;其它带有效坐标的也接受。 + return when (location.locType) { + BDLocation.TypeGpsLocation, + BDLocation.TypeNetWorkLocation, + BDLocation.TypeOffLineLocation, + BDLocation.TypeCacheLocation, + -> true + else -> location.radius > 0 + } + } + + private fun copyMap(source: WritableMap): WritableMap { + val copy = Arguments.createMap() + copy.merge(source) + return copy + } + } +} diff --git a/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt new file mode 100644 index 00000000..ba27aca8 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/android/src/main/java/com/timeflow/baidulocation/BaiduLocationPackage.kt @@ -0,0 +1,18 @@ +package com.timeflow.baidulocation + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class BaiduLocationPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(BaiduLocationModule(reactContext)) + } + + override fun createViewManagers( + reactContext: ReactApplicationContext, + ): List> { + return emptyList() + } +} diff --git a/frontend/modules/timeflow-baidu-location/index.js b/frontend/modules/timeflow-baidu-location/index.js new file mode 100644 index 00000000..1d760182 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/index.js @@ -0,0 +1,3 @@ +// Native module is linked via React Native autolinking (BaiduLocationPackage). +// JS callers use NativeModules.TimeflowBaiduLocation from the app layer. +module.exports = {}; diff --git a/frontend/modules/timeflow-baidu-location/package.json b/frontend/modules/timeflow-baidu-location/package.json new file mode 100644 index 00000000..6ebff60e --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/package.json @@ -0,0 +1,12 @@ +{ + "name": "timeflow-baidu-location", + "version": "1.0.0", + "description": "Native Android Baidu LocationClient bridge for Timeflow (no Google geofencing)", + "main": "index.js", + "license": "UNLICENSED", + "private": true, + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} diff --git a/frontend/modules/timeflow-baidu-location/react-native.config.js b/frontend/modules/timeflow-baidu-location/react-native.config.js new file mode 100644 index 00000000..3be4ed84 --- /dev/null +++ b/frontend/modules/timeflow-baidu-location/react-native.config.js @@ -0,0 +1,12 @@ +module.exports = { + dependency: { + platforms: { + android: { + sourceDir: './android', + packageImportPath: 'import com.timeflow.baidulocation.BaiduLocationPackage;', + packageInstance: 'new BaiduLocationPackage()', + }, + ios: null, + }, + }, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 052bc763..4d6b8c70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,16 +12,21 @@ "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", "expo": "~57.0.7", + "expo-audio": "~57.0.3", "expo-location": "~57.0.9", + "expo-notifications": "~57.0.10", "expo-secure-store": "~57.0.1", "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", + "expo-task-manager": "~57.0.9", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", - "rrule": "^2.8.1" + "rrule": "^2.8.1", + "timeflow-alarm": "file:modules/timeflow-alarm", + "timeflow-baidu-location": "file:modules/timeflow-baidu-location" }, "devDependencies": { "@testing-library/react-native": "13.3.3", @@ -45,6 +50,22 @@ "npm": ">=10.8.2 <11" } }, + "modules/timeflow-alarm": { + "version": "1.0.0", + "license": "UNLICENSED", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "modules/timeflow-baidu-location": { + "version": "1.0.0", + "license": "UNLICENSED", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2590,22 +2611,25 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@nolyfill/is-core-module": { @@ -4707,6 +4731,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -6888,6 +6918,55 @@ } } }, + "node_modules/expo-application": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz", + "integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-asset": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.10.tgz", + "integrity": "sha512-32QpNkWlb8ftxq3ClAriwFXcZlpzuP7Qx4z3Gc9vhbAm1QZzGsCN+PwYt3fN8W46HBw5qaI6DSXcAyBlYzeVqA==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4", + "expo-constants": "~57.0.10" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-audio": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-57.0.3.tgz", + "integrity": "sha512-FzO0gnVmlrKmNoox7xc/795uNiuuqnYBovo2kgnNICDKJ0kDi1Y5UJjqX+NATxCelZcNv5BtWs3POkKJADhNCA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.10.tgz", + "integrity": "sha512-GCDXYEsloBfouMdT3BzoGhAkcLnYxEFNLQoSbNKIvIrD9FY5MmSeWuvRSveJdOiuydwQY2iH6hy020vTaQflCQ==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-location": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-57.0.9.tgz", @@ -6945,6 +7024,24 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.10.tgz", + "integrity": "sha512-Zrwwd2eGzSuk3LyD01P5QzhiE/oXNNWaUq/NLQnPoeo63Ek2R6sWA00b0o0rl47uIqdmMtuQUfWbXgJJyc3Uag==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~57.0.2", + "expo-constants": "~57.0.10" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-secure-store": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.1.tgz", @@ -6988,6 +7085,19 @@ "react-native": "*" } }, + "node_modules/expo-task-manager": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-57.0.9.tgz", + "integrity": "sha512-98L0EIexQkAxJ1GKPAev9rJcEJvZDfZOF8vmanvXisvfpC/Z0Rsel4vzV1bVldzXwQ4rMBu3V5C9evFJQMh1PA==", + "license": "MIT", + "dependencies": { + "unimodules-app-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.9.tgz", @@ -7247,34 +7357,6 @@ "node": ">=8" } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.6.tgz", - "integrity": "sha512-n3Yb1VxcP+BMRTyC4R1x2It4+m5EDkNXiVCHGWbnIREQUUkMs2Yeul7D5qfFWAYtIn2Z3hbGMndwU6Az1FPSEg==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.11.3", - "expo-constants": "~57.0.6" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-constants": { - "version": "57.0.6", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.6.tgz", - "integrity": "sha512-OV+4XUshdO18TKNlo1cxUkXeJWgUOPgalvl8ofmc7kmPPHoyfz2hGJ94tyY/RND/GG5RREE+me9YHClNEzo+Ow==", - "license": "MIT", - "dependencies": { - "@expo/env": "~2.4.2" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-file-system": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.1.tgz", @@ -13842,6 +13924,14 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/timeflow-alarm": { + "resolved": "modules/timeflow-alarm", + "link": true + }, + "node_modules/timeflow-baidu-location": { + "resolved": "modules/timeflow-baidu-location", + "link": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -14246,6 +14336,12 @@ "node": ">=4" } }, + "node_modules/unimodules-app-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-57.0.1.tgz", + "integrity": "sha512-wey5ChoJkCTq0j0JWdIMu2QB81vVrdhmrNAP14ZZ6WDslnZ7ff7Ezv8rMdEnVHaCKz3xK4mIVXbVU51xHgdyCA==", + "license": "MIT" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a8e99ea7..5bd00d59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,16 +11,21 @@ "@expo/metro-runtime": "~57.0.8", "@irvingouj/expo-audio-stream": "3.1.0", "expo": "~57.0.7", + "expo-audio": "~57.0.3", "expo-location": "~57.0.9", + "expo-notifications": "~57.0.10", "expo-secure-store": "~57.0.1", "expo-sqlite": "~57.0.1", "expo-status-bar": "~57.0.1", + "expo-task-manager": "~57.0.9", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", - "rrule": "^2.8.1" + "rrule": "^2.8.1", + "timeflow-alarm": "file:modules/timeflow-alarm", + "timeflow-baidu-location": "file:modules/timeflow-baidu-location" }, "devDependencies": { "@testing-library/react-native": "13.3.3", diff --git a/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch b/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch index 5c352fc2..6936bf65 100644 --- a/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch +++ b/frontend/patches/@irvingouj+expo-audio-stream+3.1.0.patch @@ -1,3 +1,26 @@ +diff --git a/node_modules/@irvingouj/expo-audio-stream/android/build.gradle b/node_modules/@irvingouj/expo-audio-stream/android/build.gradle +index c3038e4..dde8fe9 100644 +--- a/node_modules/@irvingouj/expo-audio-stream/android/build.gradle ++++ b/node_modules/@irvingouj/expo-audio-stream/android/build.gradle +@@ -63,6 +63,18 @@ android { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.majorVersion + } ++ } else { ++ // AGP>=8 时 javac 走 AGP 自己的默认值(17),但这个模块的 buildscript 单独 ++ // pin 了一份 Kotlin Gradle Plugin,没有显式 jvmTarget 就会用跑 Gradle 的 ++ // JDK(这台机器是 21)当目标,跟 javac 对不上,编译直接失败。 ++ compileOptions { ++ sourceCompatibility JavaVersion.VERSION_17 ++ targetCompatibility JavaVersion.VERSION_17 ++ } ++ ++ kotlinOptions { ++ jvmTarget = JavaVersion.VERSION_17.majorVersion ++ } + } + + namespace "expo.modules.audiostream" diff --git a/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt b/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt index 9ee227e..0373656 100644 --- a/node_modules/@irvingouj/expo-audio-stream/android/src/main/java/expo/modules/audiostream/AudioRecorderManager.kt diff --git a/frontend/plugins/withTimeflowAlarm.js b/frontend/plugins/withTimeflowAlarm.js new file mode 100644 index 00000000..aebc28b1 --- /dev/null +++ b/frontend/plugins/withTimeflowAlarm.js @@ -0,0 +1,29 @@ +const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('expo/config-plugins'); + +const PACKAGE_NAME = 'timeflow-alarm'; +const PERMISSIONS = [ + 'android.permission.POST_NOTIFICATIONS', + 'android.permission.SCHEDULE_EXACT_ALARM', + 'android.permission.SYSTEM_ALERT_WINDOW', + 'android.permission.USE_FULL_SCREEN_INTENT', + 'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS', + 'android.permission.VIBRATE', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK', +]; + +/** + * 保证应用级闹钟权限在 prebuild 后仍然保留。 + * 原生源码、AlarmPackage 自动链接与组件声明在 modules/timeflow-alarm + * (经 Android library manifest 合并)。 + */ +function withTimeflowAlarm(config) { + config = AndroidConfig.Permissions.withPermissions(config, PERMISSIONS); + config = withAndroidManifest(config, (config) => { + AndroidConfig.Permissions.ensurePermissions(config.modResults, PERMISSIONS); + return config; + }); + return config; +} + +module.exports = createRunOncePlugin(withTimeflowAlarm, PACKAGE_NAME, '1.0.0'); diff --git a/frontend/plugins/withTimeflowBaiduLocation.js b/frontend/plugins/withTimeflowBaiduLocation.js new file mode 100644 index 00000000..4a103523 --- /dev/null +++ b/frontend/plugins/withTimeflowBaiduLocation.js @@ -0,0 +1,59 @@ +const { AndroidConfig, createRunOncePlugin, withAndroidManifest } = require('expo/config-plugins'); + +const PACKAGE_NAME = 'timeflow-baidu-location'; +const PERMISSIONS = [ + 'android.permission.ACCESS_COARSE_LOCATION', + 'android.permission.ACCESS_FINE_LOCATION', + 'android.permission.ACCESS_BACKGROUND_LOCATION', + 'android.permission.ACCESS_WIFI_STATE', + 'android.permission.ACCESS_NETWORK_STATE', + 'android.permission.CHANGE_WIFI_STATE', + 'android.permission.INTERNET', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_LOCATION', +]; + +/** + * 注入百度定位 AK(com.baidu.lbsapi.API_KEY)与相关权限。 + * 原生 LocationClient / Service 在 modules/timeflow-baidu-location。 + * + * app.json: + * ["./plugins/withTimeflowBaiduLocation", { "apiKey": "YOUR_AK" }] + */ +function withTimeflowBaiduLocation(config, props = {}) { + const apiKey = typeof props.apiKey === 'string' ? props.apiKey.trim() : ''; + if (!apiKey) { + throw new Error( + 'withTimeflowBaiduLocation: missing apiKey. Pass { apiKey } in app.json plugins.', + ); + } + + config = AndroidConfig.Permissions.withPermissions(config, PERMISSIONS); + config = withAndroidManifest(config, (config) => { + AndroidConfig.Permissions.ensurePermissions(config.modResults, PERMISSIONS); + const app = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults); + ensureMetaData(app, 'com.baidu.lbsapi.API_KEY', apiKey); + return config; + }); + return config; +} + +function ensureMetaData(application, name, value) { + if (!application['meta-data']) { + application['meta-data'] = []; + } + const list = application['meta-data']; + const existing = list.find((item) => item?.$?.['android:name'] === name); + if (existing) { + existing.$['android:value'] = value; + return; + } + list.push({ + $: { + 'android:name': name, + 'android:value': value, + }, + }); +} + +module.exports = createRunOncePlugin(withTimeflowBaiduLocation, PACKAGE_NAME, '1.0.0'); diff --git a/frontend/react-native.config.js b/frontend/react-native.config.js new file mode 100644 index 00000000..575aca2e --- /dev/null +++ b/frontend/react-native.config.js @@ -0,0 +1,12 @@ +const path = require('path'); + +module.exports = { + dependencies: { + 'timeflow-alarm': { + root: path.join(__dirname, 'modules/timeflow-alarm'), + }, + 'timeflow-baidu-location': { + root: path.join(__dirname, 'modules/timeflow-baidu-location'), + }, + }, +}; diff --git a/frontend/src/app/AppProviders.tsx b/frontend/src/app/AppProviders.tsx index 9fb78e24..62ee61fc 100644 --- a/frontend/src/app/AppProviders.tsx +++ b/frontend/src/app/AppProviders.tsx @@ -1,7 +1,8 @@ -import { type PropsWithChildren, useEffect } from 'react'; +import { type PropsWithChildren, useCallback, useEffect } from 'react'; import type { AuthController, AuthInvalidationCoordinator } from '../features/auth/application'; import { AuthProvider, useAuth } from '../features/auth/presentation/AuthProvider'; +import { useReminderPermissionsOnLaunch } from '../features/reminder'; import { AppServicesProvider } from './composition/AppServicesProvider'; import type { AppServices } from './composition/createAppServices'; @@ -19,26 +20,37 @@ export function AppProviders({ return ( - + {children} ); } -function AuthenticatedRuntime({ runtime }: { readonly runtime: AppServices['runtime'] }) { +function AuthenticatedRuntime({ services }: { readonly services: AppServices }) { const { viewState } = useAuth(); + const isAuthenticated = viewState.status === 'authenticated'; + + const onPermissionsUpdated = useCallback(() => { + void services.reminder.rebuild(); + }, [services]); + + useReminderPermissionsOnLaunch( + isAuthenticated ? services.reminderPorts.device : null, + isAuthenticated ? services.alertDialog : null, + onPermissionsUpdated, + ); useEffect(() => { - if (viewState.status !== 'authenticated') { + if (!isAuthenticated) { return; } - void runtime.start(); + void services.runtime.start(); return () => { - void runtime.stop(); + void services.runtime.stop(); }; - }, [runtime, viewState.status]); + }, [isAuthenticated, services]); return null; } diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx index dcde474a..6df0e7a5 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -10,6 +10,7 @@ import { AuthenticatedVoiceTransport } from '../features/assistant/data/websocke import { LocalScheduleWriter } from '../features/assistant/data/local/LocalScheduleWriter'; import type { AuthController } from '../features/auth/application'; import { useAuth } from '../features/auth/presentation/AuthProvider'; +import type { SqliteLocalScheduleReader, SqliteReminderStateStore } from '../features/reminder'; import { SqliteScheduleClientService } from '../features/schedule/application'; import { ScheduleLocalRepository } from '../features/schedule/data'; import { RNAppStateProvider } from '../infrastructure/appState/RNAppStateProvider'; @@ -37,14 +38,22 @@ export function AppRoot({ invalidationCoordinator={authController ? undefined : services.auth.invalidationCoordinator} services={services} > - + ); } function AuthRoute({ + reminderState, + scheduleReader, webSocketClient, }: { + readonly reminderState: SqliteReminderStateStore; + readonly scheduleReader: SqliteLocalScheduleReader; readonly webSocketClient: AuthenticatedWebSocketClient; }) { const { retryInitialization, viewState } = useAuth(); @@ -77,6 +86,8 @@ function AuthRoute({ @@ -96,10 +107,14 @@ type ScheduleLoadState = function AuthenticatedScheduleRoute({ accountId, + reminderState, + scheduleReader, username, webSocketClient, }: { readonly accountId: string; + readonly reminderState: SqliteReminderStateStore; + readonly scheduleReader: SqliteLocalScheduleReader; readonly username: string; readonly webSocketClient: AuthenticatedWebSocketClient; }) { @@ -150,6 +165,24 @@ function AuthenticatedScheduleRoute({ [currentLoadState], ); + // 提醒引擎(LocalReminderApplication)在认证态一变化就 start(),早于这里数据库 + // 打开完成;SqliteLocalScheduleReader / SqliteReminderStateStore 都是整个 App + // 生命周期唯一一个实例,attach() 只是把它们接到这次登录/重试打开的仓储上。 + // reminderState 要先于 refresh() 触发的 rebuild 接上,不然 rebuild 读到的运行时 + // 状态还是空的,会把已经响过的提醒当成没响过又送一遍。 + useEffect(() => { + if (currentLoadState?.status !== 'ready') { + return; + } + reminderState.attach(currentLoadState.repository, accountId); + scheduleReader.attach(currentLoadState.repository, accountId); + void scheduleReader.refresh(); + return () => { + scheduleReader.detach(); + reminderState.detach(); + }; + }, [accountId, currentLoadState, reminderState, scheduleReader]); + // 语音这条连接复用应用唯一的 AuthenticatedWebSocketClient——握手、鉴权失效、 // 断线通知都由它统一处理,这里不再单独持有 access_token/device_id/wsUrl。 // 按住说话和免提通话共用同一批 capture/playback/location/appState 端口 @@ -166,11 +199,11 @@ function AuthenticatedScheduleRoute({ return { appState: new RNAppStateProvider(), capture: new ExpoAudioCapture(), - localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository), + localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository, scheduleReader), location: new ExpoLocationProvider(), playback: new ExpoAudioPlayback(), }; - }, [currentLoadState]); + }, [currentLoadState, scheduleReader]); const pushToTalkApplication = useMemo(() => { if (assistantDependencies === null) { diff --git a/frontend/src/app/composition/.gitkeep b/frontend/src/app/composition/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/app/composition/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 66823c6c..474283cd 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -1,30 +1,41 @@ import { AppRuntime } from '../orchestration/AppRuntime'; import { createAuthRuntime, type AuthRuntime, type CreateAuthRuntimeOptions } from '../authRuntime'; import type { + AlertDialogPort, ReminderApplicationDependencies, ReminderApplicationPort, } from '../../features/reminder/application/interfaces'; +import { LocalReminderApplication } from '../../features/reminder/application'; import { - MockLocalScheduleReader, - MockReminderApplication, - MockReminderDispositionSync, - MockReminderStateStore, + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + LocalSystemNotification, + NoopPopup, + SqliteLocalScheduleReader, + SqliteReminderStateStore, } from '../../features/reminder/data/local'; -import { MockAudioPlayback } from '../../infrastructure/audio'; -import { MockLocationMonitor } from '../../infrastructure/location'; +import { AlertReminderPresenter } from '../../features/reminder/presentation'; +import { ExpoAudioPlayback } from '../../infrastructure/audio'; +// NativeLocationMonitor(百度定位 SDK)保留在仓库里没删,只是这次没接进来——现在用的 +// 是 ExpoLocationMonitor(系统原生地理围栏),不依赖百度控制台那个 AK 注册。百度那个 +// Key 配置好之后如果想切回去,把下面这行 import 和 reminderPorts.location 换回来就行。 +import { ExpoLocationMonitor } from '../../infrastructure/location'; import { - MockPopup, - MockReminderRecovery, - MockReminderDelivery, - MockSystemNotification, - MockVibration, NativeAlarmScheduler, NativeDeviceCapability, + ReactNativeAlertDialog, + ReactNativeVibration, } from '../../infrastructure/notifications'; -import { MockTimeListener } from '../../shared/time'; -import { MockReminderPresenter } from '../../features/reminder/presentation'; +import { IntervalTimeListener } from '../../infrastructure/time'; import { ScheduleViewStore } from '../../features/schedule/presentation'; +export interface CreateAppServicesOptions { + readonly auth?: CreateAuthRuntimeOptions; + readonly schedules?: SqliteLocalScheduleReader; + readonly overrides?: Partial; +} + export type AppServices = { auth: AuthRuntime; protectedClient: AuthRuntime['protectedClient']; @@ -33,32 +44,47 @@ export type AppServices = { reminderPorts: ReminderApplicationDependencies; scheduleView: ScheduleViewStore; webSocketClient: AuthRuntime['webSocketClient']; + /** 需要 attach()/detach()/refresh() 时用这些具体类型;LocalReminderApplication 只经 reminderPorts 访问端口接口。 */ + schedules: SqliteLocalScheduleReader; + reminderState: SqliteReminderStateStore; + alertDialog: AlertDialogPort; }; -export interface CreateAppServicesOptions { - readonly auth?: CreateAuthRuntimeOptions; -} - /** 应用唯一组合根:认证传输、功能服务、生命周期和账号内存清理在此接线。 */ export function createAppServices(options: CreateAppServicesOptions = {}): AppServices { const auth = createAuthRuntime(options.auth); + const alertDialog = new ReactNativeAlertDialog(); + const schedules = options.schedules ?? new SqliteLocalScheduleReader(); + const reminderState = new SqliteReminderStateStore(); + const presenter = + (options.overrides?.presenter as AlertReminderPresenter | undefined) ?? + new AlertReminderPresenter(alertDialog); + + const { + schedules: _ignoredSchedules, + presenter: _ignoredPresenter, + ...restOverrides + } = options.overrides ?? {}; + const reminderPorts: ReminderApplicationDependencies = { - schedules: new MockLocalScheduleReader(), - time: new MockTimeListener(), - location: new MockLocationMonitor(), + time: new IntervalTimeListener(), + location: new ExpoLocationMonitor(), alarms: new NativeAlarmScheduler(), - delivery: new MockReminderDelivery(), - audio: new MockAudioPlayback(), + delivery: new LocalReminderDelivery(), + audio: new ExpoAudioPlayback(), device: new NativeDeviceCapability(), - presenter: new MockReminderPresenter(), - systemNotification: new MockSystemNotification(), - popup: new MockPopup(), - vibration: new MockVibration(), - recovery: new MockReminderRecovery(), - state: new MockReminderStateStore(), - dispositionSync: new MockReminderDispositionSync(), + systemNotification: new LocalSystemNotification(), + popup: new NoopPopup(), + vibration: new ReactNativeVibration(), + recovery: new LocalReminderRecovery(), + state: reminderState, + dispositionSync: new LocalReminderDispositionSync(), + ...restOverrides, + schedules, + presenter, }; - const reminder = new MockReminderApplication(reminderPorts); + + const reminder = new LocalReminderApplication(reminderPorts); const scheduleView = new ScheduleViewStore(); const runtime = new AppRuntime([ { @@ -78,5 +104,8 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe reminderPorts, scheduleView, webSocketClient: auth.webSocketClient, + schedules, + reminderState, + alertDialog, }; } diff --git a/frontend/src/app/orchestration/.gitkeep b/frontend/src/app/orchestration/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/app/orchestration/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts index b8b432ef..9133c088 100644 --- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts +++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts @@ -1,3 +1,4 @@ +import type { SqliteLocalScheduleReader } from '../../../reminder'; import type { CloudScheduleRow, ScheduleLocalRepository } from '../../../schedule/data'; import type { LocalScheduleWriterPort } from '../../application/interfaces/LocalScheduleWriterPort'; import type { AppliedCommand } from '../../domain/ConversationTurn'; @@ -23,13 +24,19 @@ import type { AppliedCommand } from '../../domain/ConversationTurn'; * 同步,得等后端把 override 数据也传下来。 */ export class LocalScheduleWriter implements LocalScheduleWriterPort { - public constructor(private readonly repository: ScheduleLocalRepository) {} + public constructor( + private readonly repository: ScheduleLocalRepository, + private readonly scheduleReader?: SqliteLocalScheduleReader, + ) {} public async applyCommandResult(accountId: string, command: AppliedCommand): Promise { if (command.status !== 'applied' || !command.schedule) { return; } await this.repository.applyCloudSchedule(toCloudScheduleRow(accountId, command.schedule)); + // 提醒引擎读的是 SqliteLocalScheduleReader 的投影,不是这个仓储本身;写完 + // 必须主动刷新一次,不然新建/改动的日程要等下次 rebuild 才会被提醒引擎看到。 + await this.scheduleReader?.refresh(); } } diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index e8904c8a..0de4509f 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -7,8 +7,10 @@ import type { LocationMonitorEvent, LocationWatchHandle, AlarmScheduleReceipt, + AlarmNativeEvent, } from './interfaces'; import type { + DeliveryChannel, LocalReminderSchedule, LocationSample, ReminderDeliveryReceipt, @@ -21,6 +23,7 @@ import type { } from '../domain'; import { DEFAULT_SNOOZE_MINUTES } from '../domain'; import { evaluateGeofence, resolveGeofenceCenter, resolveWatchMode } from '../domain/geofence'; +import { resolveStrengthDeliveryPlan } from '../domain/strengthDelivery'; import { isSnoozeActive, isSnoozeExpired, @@ -50,9 +53,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { private timeListenerId: string | null = null; private unsubscribePresenter: (() => void) | null = null; private unsubscribeSchedules: (() => void) | null = null; + private unsubscribeAlarms: (() => void) | null = null; private readonly registrations = new Map(); private readonly activeDeliveries = new Set(); private readonly deliverLocks = new Set(); + /** 原生已响铃、由原生 UI 承接展示的日程;JS 不再叠加弹窗/TTS。 */ + private readonly nativePresented = new Set(); private readonly inFlight = new Set>(); private opChain: Promise = Promise.resolve(); /** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */ @@ -161,6 +167,10 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.unsubscribePresenter = this.dependencies.presenter.onAction((event) => { void this.handlePresentationAction(event.schedule_id, event.action); }); + this.unsubscribeAlarms = + this.dependencies.alarms.subscribe?.((event) => { + void this.handleNativeAlarmEvent(event); + }) ?? null; // IntervalTimeListener 不在 start 时同步打点;先挂上 listener id 再 rebuild。 const timeHandle = await this.dependencies.time.start( @@ -175,6 +185,12 @@ export class LocalReminderApplication implements ReminderApplicationPort { return; } + await this.hydrateNativeDispositions(); + if (!this.isLive(generation)) { + await this.stopInternal(); + return; + } + this.unsubscribeSchedules = this.dependencies.schedules.subscribe(() => { void this.enqueueRebuild(); }); @@ -209,6 +225,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { } this.activeDeliveries.clear(); this.deliverLocks.clear(); + this.nativePresented.clear(); this.started = false; } @@ -217,6 +234,8 @@ export class LocalReminderApplication implements ReminderApplicationPort { this.unsubscribePresenter = null; this.unsubscribeSchedules?.(); this.unsubscribeSchedules = null; + this.unsubscribeAlarms?.(); + this.unsubscribeAlarms = null; } private async registerInternal( @@ -283,8 +302,22 @@ export class LocalReminderApplication implements ReminderApplicationPort { if (!this.isLive(generation)) return []; - const locationSchedules = active.filter((schedule) => schedule.schedule_type === 'location'); - const locationHandles = await this.dependencies.location.rebuild(locationSchedules, (event) => { + const locationTargets = active + .filter((schedule) => schedule.schedule_type === 'location') + .map((schedule) => { + const mode = resolveWatchMode(schedule); + const center = resolveGeofenceCenter(schedule, mode); + if (center == null) return null; + return { + schedule_id: schedule.id, + center, + radius_meters: schedule.geofence_radius_meters, + mode, + background: true, + }; + }) + .filter((target): target is NonNullable => target != null); + const locationHandles = await this.dependencies.location.rebuild(locationTargets, (event) => { void this.handleLocationMonitorEvent(event); }); const locationBySchedule = new Map( @@ -563,25 +596,58 @@ export class LocalReminderApplication implements ReminderApplicationPort { }); const request = toDeliveryRequest(schedule, trigger); - const receipt = await this.dependencies.delivery.deliver(request); - await this.dependencies.presenter.show(request); - await this.dependencies.systemNotification.show({ - notification_id: `reminder-${schedule.id}`, - title: schedule.title, - body: schedule.location_name ?? schedule.title, - }); - await this.dependencies.popup.show({ - popup_id: `reminder-${schedule.id}`, - title: schedule.title, - body: schedule.location_name ?? schedule.title, - }); - await this.dependencies.vibration.vibrate(); - - let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); - if (!audioReceipt.played) { - audioReceipt = await this.dependencies.audio.playLocalFallback({ - schedule_id: schedule.id, + const plan = resolveStrengthDeliveryPlan(request.strength); + const channels: DeliveryChannel[] = []; + let usedFallbackAudio = false; + let deliveryId = `delivery-${schedule.id}`; + + // 时间型日程只要排上了原生精确闹钟,响铃 UI 和声音就全权交给原生 + // RingActivity/AlarmSoundService;JS 这边再弹 Alert/放音会跟原生的全屏页 + // 抢事件,谁都关不掉谁。地点型日程没有原生等价物,走完整 JS 通道。 + const nativeAlarmOwnsRingUi = + schedule.schedule_type === 'time' && + this.registrations.get(schedule.id)?.alarm_id != null; + + // low=系统通知;medium=弹窗+短震动;high=弹窗+短震动+TTS(失败则本地音)。 + if (plan.useSystemNotification) { + const receipt = await this.dependencies.delivery.deliver(request); + deliveryId = receipt.delivery_id; + await this.dependencies.systemNotification.show({ + notification_id: `reminder-${schedule.id}`, + title: schedule.title, + body: schedule.location_name ?? schedule.title, }); + channels.push('system_notification'); + } + + if (plan.usePopup && !nativeAlarmOwnsRingUi) { + await this.dependencies.presenter.show(request); + channels.push('popup'); + } + + if (plan.useVibration) { + await this.dependencies.vibration.vibrate(); + channels.push('vibration'); + } + + let audioPlayed = false; + if (plan.useAudio && !nativeAlarmOwnsRingUi) { + let audioReceipt = await this.dependencies.audio.playTts({ schedule_id: schedule.id }); + if (!audioReceipt.played) { + audioReceipt = await this.dependencies.audio.playLocalFallback({ + schedule_id: schedule.id, + }); + } + audioPlayed = audioReceipt.played; + if (audioPlayed) { + channels.push(audioReceipt.used_local_fallback ? 'local_sound' : 'tts'); + } + usedFallbackAudio = audioReceipt.used_local_fallback; + } + + await this.cancelScheduledAlarm(schedule.id); + if (!plan.useAudio || audioPlayed) { + await this.dependencies.alarms.stopRinging?.(); } if (!this.isLive(generation)) { @@ -593,14 +659,11 @@ export class LocalReminderApplication implements ReminderApplicationPort { } return { - ...receipt, - channels: [ - ...receipt.channels, - 'popup', - 'vibration', - audioReceipt.used_local_fallback ? 'local_sound' : 'tts', - ], - used_fallback_audio: audioReceipt.used_local_fallback, + delivery_id: deliveryId, + schedule_id: schedule.id, + delivered_at: trigger.triggered_at, + channels, + used_fallback_audio: usedFallbackAudio, }; } catch (error) { if (!this.acceptingWork || this.isLive(generation)) { @@ -641,12 +704,83 @@ export class LocalReminderApplication implements ReminderApplicationPort { } await this.enqueueOp(() => this.snoozeInternal( - { schedule_id: scheduleId, snooze_minutes: DEFAULT_SNOOZE_MINUTES }, + { schedule_id: scheduleId, snooze_until: null, snooze_minutes: DEFAULT_SNOOZE_MINUTES }, generation, ), ); } + private async handleNativeAlarmEvent(event: AlarmNativeEvent): Promise { + if (!event.schedule_id) return; + if (event.type === 'fired') { + await this.acknowledgeNativeFire(event.schedule_id, event.at); + return; + } + if (event.type === 'snoozed') { + await this.snooze({ schedule_id: event.schedule_id, snooze_until: null }); + return; + } + await this.confirm(event.schedule_id, event.at); + } + + /** + * 原生已承接响铃 UI:只落 pending disposition,避免 JS 再弹 Alert/TTS。 + * handleTime 会因 pending / activeDeliveries 跳过,从而消除双通道连响。 + */ + private async acknowledgeNativeFire(scheduleId: string, firedAt: string): Promise { + const runtime = (await this.readRuntime(scheduleId)) ?? emptyRuntime(); + if ( + runtime.reminder_disposition_state === 'confirmed' || + runtime.reminder_disposition_state === 'pending' + ) { + this.nativePresented.add(scheduleId); + this.activeDeliveries.add(scheduleId); + return; + } + + this.nativePresented.add(scheduleId); + this.activeDeliveries.add(scheduleId); + await this.cancelScheduledAlarm(scheduleId); + await this.patchRuntime(scheduleId, { + ...runtime, + reminder_disposition_state: 'pending', + next_trigger_at: null, + disposition_updated_at: firedAt, + sync_status: 'pending', + }); + await this.dependencies.state.setDisposition(scheduleId, { + schedule_id: scheduleId, + state: 'pending', + updated_at: firedAt, + snoozed_until: null, + sync_status: 'pending', + }); + } + + private async hydrateNativeDispositions(): Promise { + const rows = await this.dependencies.alarms.consumeNativeDispositions?.(); + if (rows == null || rows.length === 0) return; + + for (const row of rows) { + if (row.state === 'confirmed') { + await this.confirm(row.schedule_id, row.updated_at); + continue; + } + if (row.state === 'snoozed') { + await this.snooze({ schedule_id: row.schedule_id, snooze_until: null }); + continue; + } + await this.acknowledgeNativeFire(row.schedule_id, row.updated_at); + } + } + + private async cancelScheduledAlarm(scheduleId: string): Promise { + const registration = this.registrations.get(scheduleId); + if (registration?.alarm_id == null) return; + await this.dependencies.alarms.cancel(registration.alarm_id); + registration.alarm_id = null; + } + private async handleLocationMonitorEvent(event: LocationMonitorEvent): Promise { const generation = this.generation; await this.track(this.runLocationMonitorEvent(event, generation)); diff --git a/frontend/src/features/reminder/application/index.ts b/frontend/src/features/reminder/application/index.ts index 6e87c456..63351bc7 100644 --- a/frontend/src/features/reminder/application/index.ts +++ b/frontend/src/features/reminder/application/index.ts @@ -1,5 +1,7 @@ export { LocalReminderApplication } from './LocalReminderApplication'; export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -13,9 +15,13 @@ export type { LocalTimeTick, LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -24,6 +30,8 @@ export type { ReminderApplicationResult, ReminderConfirmedDisposition, ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, ReminderDispositionSyncPort, ReminderDispositionSyncReceipt, ReminderPresentationAction, diff --git a/frontend/src/features/reminder/application/interfaces/.gitkeep b/frontend/src/features/reminder/application/interfaces/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/application/interfaces/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts index 17949c02..b7bb86fc 100644 --- a/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts +++ b/frontend/src/features/reminder/application/interfaces/AlarmSchedulerPort.ts @@ -12,9 +12,30 @@ export type AlarmScheduleReceipt = { scheduled: boolean; }; +export type AlarmNativeEvent = { + type: 'fired' | 'dismissed' | 'snoozed'; + schedule_id: string; + alarm_id: string; + title: string; + at: string; +}; + +export type AlarmNativeDisposition = { + schedule_id: string; + alarm_id: string; + state: 'pending' | 'confirmed' | 'snoozed'; + updated_at: string; +}; + /** 原生闹钟映射边界;触发时间的选择留在应用层或领域层。 */ export interface AlarmSchedulerPort { schedule(request: AlarmScheduleRequest): Promise; cancel(alarmId: string | null): Promise<{ cancelled: boolean }>; rebuild(requests: readonly AlarmScheduleRequest[]): Promise; + /** 停止正在响铃的原生界面/音频(尽力而为)。 */ + stopRinging?(): Promise; + /** 订阅原生响铃/停铃事件;无原生桥时可不实现。 */ + subscribe?(listener: (event: AlarmNativeEvent) => void): () => void; + /** 拉取并清空进程外写入的处置状态(冷启动补水)。 */ + consumeNativeDispositions?(): Promise; } diff --git a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts index 9ba6ae5c..1eb2b688 100644 --- a/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts +++ b/frontend/src/features/reminder/application/interfaces/DeviceCapabilityPort.ts @@ -19,4 +19,6 @@ export interface DeviceCapabilityPort { getStatus(): Promise; requestPermission(permission: DevicePermission): Promise; openSettings(permission: DevicePermission): Promise; + /** 订阅应用回到前台;返回取消订阅函数。 */ + onAppActive(listener: () => void): () => void; } diff --git a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts index 05711ee8..aa2dfa58 100644 --- a/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts +++ b/frontend/src/features/reminder/application/interfaces/LocationMonitorPort.ts @@ -1,7 +1,19 @@ -import type { GeoPoint, LocalReminderSchedule, LocationSample } from '../../domain'; +import type { GeoPoint, LocationSample } from '../../domain'; export type LocationWatchMode = 'arrive' | 'return'; +/** + * 围栏重建目标:携带 watch 所需几何参数,避免 infrastructure 依赖完整领域日程, + * 同时保证冷启动 rebuild 能重新挂上系统围栏。 + */ +export type LocationRebuildTarget = { + schedule_id: string; + center: GeoPoint; + radius_meters: number; + mode: LocationWatchMode; + background: boolean; +}; + export type LocationWatchRequest = { schedule_id: string; center: GeoPoint; @@ -29,7 +41,7 @@ export interface LocationMonitorPort { ): Promise; unwatch(listenerId: string): Promise; rebuild( - schedules: readonly LocalReminderSchedule[], + targets: readonly LocationRebuildTarget[], listener: (event: LocationMonitorEvent) => void, ): Promise; getLastSample(): Promise; diff --git a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts index 3c62b6e9..c9706ee5 100644 --- a/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts +++ b/frontend/src/features/reminder/application/interfaces/NotificationChannels.ts @@ -34,3 +34,23 @@ export interface VibrationPort { vibrate(): Promise; stop(): Promise; } + +export type AlertDialogButton = { + text: string; + style?: 'default' | 'cancel' | 'destructive'; + onPress?: () => void; +}; + +export type AlertDialogRequest = { + title: string; + message: string; + buttons: readonly AlertDialogButton[]; + /** Android:返回键关闭时回调;未点按钮也要能结束等待。 */ + onDismiss?: () => void; + cancelable?: boolean; +}; + +/** 系统确认/选择对话框;由 infrastructure 适配,presentation 不直接依赖 RN Alert。 */ +export interface AlertDialogPort { + show(request: AlertDialogRequest): Promise; +} diff --git a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts index 903b1dbd..5f0b4fe8 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts @@ -24,21 +24,11 @@ export type ReminderApplicationDependencies = { dispositionSync: import('./ReminderDispositionSyncPort').ReminderDispositionSyncPort; }; -/** - * 贪睡必须且只能提供一种目标时刻表达:绝对时间或相对分钟。 - * 两种字段互斥,避免实现层自行决定优先级,也禁止无目标时刻的请求。 - */ -export type ReminderSnoozeRequest = - | { - schedule_id: string; - snooze_until: string; - snooze_minutes?: never; - } - | { - schedule_id: string; - snooze_minutes: number; - snooze_until?: never; - }; +export type ReminderSnoozeRequest = { + schedule_id: string; + snooze_until: string | null; + snooze_minutes?: number | null; +}; export type ReminderApplicationResult = { accepted: boolean; diff --git a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts index f13ba733..611712a6 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderDeliveryPort.ts @@ -1,5 +1,7 @@ import type { ReminderDeliveryReceipt, ReminderDeliveryRequest } from '../../domain'; +export type { ReminderDeliveryReceipt, ReminderDeliveryRequest }; + /** 通过与平台无关的展示边界送达提醒。 */ export interface ReminderDeliveryPort { deliver(request: ReminderDeliveryRequest): Promise; diff --git a/frontend/src/features/reminder/application/interfaces/index.ts b/frontend/src/features/reminder/application/interfaces/index.ts index ca99f198..788b9f5d 100644 --- a/frontend/src/features/reminder/application/interfaces/index.ts +++ b/frontend/src/features/reminder/application/interfaces/index.ts @@ -1,4 +1,6 @@ export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -17,11 +19,15 @@ export type { LocalScheduleReader } from './LocalScheduleReader'; export type { LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, } from './LocationMonitorPort'; export type { + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -36,7 +42,11 @@ export type { ReminderApplicationResult, ReminderSnoozeRequest, } from './ReminderApplicationPort'; -export type { ReminderDeliveryPort } from './ReminderDeliveryPort'; +export type { + ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, +} from './ReminderDeliveryPort'; export type { ReminderConfirmedDisposition, ReminderDispositionSyncPort, diff --git a/frontend/src/features/reminder/data/local/.gitkeep b/frontend/src/features/reminder/data/local/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/data/local/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts new file mode 100644 index 00000000..fd175135 --- /dev/null +++ b/frontend/src/features/reminder/data/local/InMemoryLocalScheduleReader.ts @@ -0,0 +1,61 @@ +import type { LocalScheduleReader } from '../../application/interfaces'; +import type { LocalReminderSchedule } from '../../domain'; + +/** + * 进程内日程投影,供调用方写入后再由 LocalReminderApplication rebuild/start 接管。 + * 正式本地 DB 接入前作为默认 LocalScheduleReader。 + */ +export class InMemoryLocalScheduleReader implements LocalScheduleReader { + private readonly byId = new Map(); + private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + + list(): readonly LocalReminderSchedule[] { + return [...this.byId.values()]; + } + + async listReminderSchedules(): Promise { + return this.list(); + } + + async getReminderSchedule(scheduleId: string): Promise { + return this.byId.get(scheduleId) ?? null; + } + + subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + replaceAll(schedules: readonly LocalReminderSchedule[]): void { + this.byId.clear(); + for (const schedule of schedules) { + this.byId.set(schedule.id, schedule); + } + this.notify(); + } + + upsert(schedule: LocalReminderSchedule): void { + this.byId.set(schedule.id, schedule); + this.notify(); + } + + remove(scheduleId: string): void { + if (!this.byId.delete(scheduleId)) return; + this.notify(); + } + + clear(): void { + if (this.byId.size === 0) return; + this.byId.clear(); + this.notify(); + } + + private notify(): void { + const snapshot = this.list(); + for (const listener of this.listeners) { + listener(snapshot); + } + } +} diff --git a/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts new file mode 100644 index 00000000..604ca59a --- /dev/null +++ b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts @@ -0,0 +1,78 @@ +import type { + PopupPort, + PopupReceipt, + PopupRequest, + ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, + ReminderDispositionSyncPort, + ReminderDispositionSyncReceipt, + ReminderRecoveryPort, + ReminderRecoveryReceipt, + SystemNotificationPort, + SystemNotificationReceipt, + SystemNotificationRequest, + ReminderConfirmedDisposition, +} from '../../application/interfaces'; + +/** 送达记账:展示由 systemNotification / presenter 完成,此处只返回回执。 */ +export class LocalReminderDelivery implements ReminderDeliveryPort { + async deliver(request: ReminderDeliveryRequest): Promise { + return { + delivery_id: `delivery-${request.schedule_id}-${Date.now()}`, + schedule_id: request.schedule_id, + delivered_at: new Date().toISOString(), + channels: [], + used_fallback_audio: false, + }; + } + + async dismiss(_scheduleId: string): Promise { + return Promise.resolve(); + } +} + +/** 弹窗通道占位:提醒页由 ReminderPresenter 承接。 */ +export class NoopPopup implements PopupPort { + async show(request: PopupRequest): Promise { + return { popup_id: request.popup_id, visible: false }; + } + + async dismiss(_popupId: string): Promise { + return Promise.resolve(); + } +} + +/** 系统通知占位:接入 expo-notifications 前保持可替换端口。 */ +export class LocalSystemNotification implements SystemNotificationPort { + async show(request: SystemNotificationRequest): Promise { + return { notification_id: request.notification_id, shown: true }; + } + + async cancel(_notificationId: string): Promise { + return Promise.resolve(); + } +} + +/** 重启恢复占位:后续可接开机广播 / 精确闹钟重挂。 */ +export class LocalReminderRecovery implements ReminderRecoveryPort { + async registerForRestart(): Promise { + return { registered: true, recovery_id: `recovery-${Date.now()}` }; + } + + async restoreAfterRestart(): Promise { + return { registered: true, recovery_id: `recovery-${Date.now()}` }; + } +} + +/** 确认态本地受理:无网络时也返回 accepted,供后续 sync 替换。 */ +export class LocalReminderDispositionSync implements ReminderDispositionSyncPort { + async submitConfirmed( + disposition: ReminderConfirmedDisposition, + ): Promise { + return { + schedule_id: disposition.schedule_id, + accepted: true, + }; + } +} diff --git a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts deleted file mode 100644 index 9b5942a4..00000000 --- a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { LocalScheduleReader } from '../../application/interfaces'; -import type { LocalReminderSchedule } from '../../domain'; - -import { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules'; - -/** 代替本地数据库端口的固定只读适配器。 */ -export class MockLocalScheduleReader implements LocalScheduleReader { - readonly schedules = MOCK_REMINDER_SCHEDULES; - private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); - - async listReminderSchedules(): Promise { - return this.schedules; - } - - async getReminderSchedule(scheduleId: string): Promise { - return this.schedules.find((schedule) => schedule.id === scheduleId) ?? null; - } - - subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - /** 测试或本地写入后通知订阅方;subscribe 本身不重放,避免与 start/rebuild 重入。 */ - notify(): void { - for (const listener of this.listeners) { - listener(this.schedules); - } - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderApplication.ts b/frontend/src/features/reminder/data/local/MockReminderApplication.ts deleted file mode 100644 index bc717c72..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderApplication.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { - ReminderApplicationDependencies, - ReminderApplicationPort, - ReminderApplicationResult, - ReminderSnoozeRequest, -} from '../../application/interfaces'; -import type { - LocalReminderSchedule, - LocationSample, - ReminderDeliveryReceipt, - ReminderRegistration, - ReminderTrigger, -} from '../../domain'; - -const MOCK_DELIVERY: ReminderDeliveryReceipt = { - delivery_id: 'mock-delivery-001', - schedule_id: 'mock-schedule-time-001', - delivered_at: '2026-08-07T01:00:00.000Z', - channels: ['system_notification', 'popup', 'vibration'], - used_fallback_audio: false, -}; - -function registrationFor(schedule: LocalReminderSchedule): ReminderRegistration { - if (schedule.schedule_type === 'location') { - return { - schedule_id: schedule.id, - time_listener_id: null, - location_listener_id: `mock-location-listener-${schedule.id}`, - alarm_id: null, - }; - } - - return { - schedule_id: schedule.id, - time_listener_id: `mock-time-listener-${schedule.id}`, - location_listener_id: null, - alarm_id: `mock-alarm-${schedule.id}`, - }; -} - -/** 真实协调器完成前供应用外壳使用的应用门面。 */ -export class MockReminderApplication implements ReminderApplicationPort { - constructor(readonly dependencies: ReminderApplicationDependencies) {} - - async start(): Promise { - return Promise.resolve(); - } - - async stop(): Promise { - return Promise.resolve(); - } - - async register(schedule: LocalReminderSchedule): Promise { - return registrationFor(schedule); - } - - async rebuild(): Promise { - const schedules = await this.dependencies.schedules.listReminderSchedules(); - return schedules.map(registrationFor); - } - - async handleTime(_tick: { observed_at: string }): Promise { - return Promise.resolve(); - } - - async handleLocation(_sample: LocationSample): Promise { - return Promise.resolve(); - } - - async deliver(trigger: ReminderTrigger): Promise { - return { ...MOCK_DELIVERY, schedule_id: trigger.schedule_id }; - } - - async confirm(scheduleId: string, confirmedAt: string): Promise { - return { - accepted: true, - schedule_id: scheduleId, - disposition: { - schedule_id: scheduleId, - state: 'confirmed', - updated_at: confirmedAt, - snoozed_until: null, - sync_status: 'pending', - }, - }; - } - - async snooze(request: ReminderSnoozeRequest): Promise { - // 确定性 mock:绝对时间原样回传;相对分钟映射到固定 fixture 时刻。 - const snoozed_until = - 'snooze_minutes' in request ? '2026-08-07T01:10:00.000Z' : request.snooze_until; - - return { - accepted: true, - schedule_id: request.schedule_id, - disposition: { - schedule_id: request.schedule_id, - state: 'snoozed', - updated_at: '2026-08-07T01:00:00.000Z', - snoozed_until, - sync_status: 'pending', - }, - }; - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts b/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts deleted file mode 100644 index 5a7ef1c5..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - ReminderConfirmedDisposition, - ReminderDispositionSyncPort, - ReminderDispositionSyncReceipt, -} from '../../application/interfaces'; - -/** 最终确认状态网络同步回调的模拟实现。 */ -export class MockReminderDispositionSync implements ReminderDispositionSyncPort { - async submitConfirmed( - disposition: ReminderConfirmedDisposition, - ): Promise { - return { - schedule_id: disposition.schedule_id, - accepted: true, - }; - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts b/frontend/src/features/reminder/data/local/MockReminderStateStore.ts deleted file mode 100644 index 187d26c4..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ReminderStateStore } from '../../application/interfaces'; -import type { ReminderDisposition, ReminderRuntimeState } from '../../domain'; - -const MOCK_STATE: ReminderRuntimeState = { - reminder_disposition_state: null, - next_trigger_at: '2026-08-07T01:00:00.000Z', - snoozed_until: null, - geofence_armed: false, - disposition_updated_at: null, - sync_status: 'synced', - recorded_location: null, -}; - -/** 读取结果固定的本地状态空操作适配器,不打开本地数据库。 */ -export class MockReminderStateStore implements ReminderStateStore { - async read(_scheduleId: string): Promise { - return { ...MOCK_STATE }; - } - - async write(_scheduleId: string, _state: ReminderRuntimeState): Promise { - return Promise.resolve(); - } - - async setDisposition(_scheduleId: string, _disposition: ReminderDisposition): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts new file mode 100644 index 00000000..b66ca3a6 --- /dev/null +++ b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts @@ -0,0 +1,102 @@ +import type { ScheduleLocalRepository, LocalScheduleRow } from '../../../schedule/data'; +import type { LocalScheduleReader } from '../../application/interfaces'; +import type { LocalReminderSchedule, ReminderStrength } from '../../domain'; + +/** local_schedules 表没有这一列;地点提醒暂时统一按 200 米围栏处理。 */ +const DEFAULT_GEOFENCE_RADIUS_METERS = 200; + +type Target = { repository: ScheduleLocalRepository; accountId: string }; + +/** + * 真实本地日程数据源:读 SQLite `local_schedules`,供 LocalReminderApplication 使用。 + * + * createAppServices() 在认证/数据库就绪之前就要把这个类接进 reminderPorts,所以构造时 + * 不直接拿 repository/accountId——由调用方在数据库打开、账号确定后调用 attach(), + * 账号切换或登出时调用 detach()。整个应用生命周期内只有这一个实例, + * LocalReminderApplication.start() 对它的 subscribe() 只挂一次,重新绑定 target 不会 + * 让那次订阅失效。 + */ +export class SqliteLocalScheduleReader implements LocalScheduleReader { + private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + private target: Target | null = null; + + public attach(repository: ScheduleLocalRepository, accountId: string): void { + this.target = { repository, accountId }; + } + + public detach(): void { + this.target = null; + } + + public async listReminderSchedules(): Promise { + if (this.target === null) return []; + const rows = await this.target.repository.listSchedules(this.target.accountId); + return rows.map(toLocalReminderSchedule); + } + + public async getReminderSchedule(scheduleId: string): Promise { + if (this.target === null) return null; + const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId); + return row === null ? null : toLocalReminderSchedule(row); + } + + public subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** attach() 之后或本地写入提交后调用:重新读取 SQLite 并通知订阅方触发 rebuild。 */ + public async refresh(): Promise { + const schedules = await this.listReminderSchedules(); + for (const listener of this.listeners) { + listener(schedules); + } + } +} + +function toLocalReminderSchedule(row: LocalScheduleRow): LocalReminderSchedule { + return { + id: row.id, + account_id: row.account_id, + title: row.title, + schedule_type: row.schedule_type, + schedule_kind: row.schedule_kind, + is_all_day: row.is_all_day === 1, + start_time: row.start_time, + end_time: row.end_time, + timezone: row.timezone, + recurrence_rule: row.recurrence_rule, + location_name: row.location_name, + latitude: row.latitude, + longitude: row.longitude, + geofence_radius_meters: DEFAULT_GEOFENCE_RADIUS_METERS, + reminder: + row.reminder_type === null + ? null + : { + reminder_type: row.reminder_type, + reminder_trigger_at: row.reminder_trigger_at, + reminder_offset_minutes: row.reminder_offset_minutes, + // reminder_type 非空时 reminder_strength 一定非空,由建表 CHECK 约束保证。 + reminder_strength: row.reminder_strength as ReminderStrength, + }, + runtime: { + reminder_disposition_state: row.reminder_disposition_state, + next_trigger_at: row.next_trigger_at, + snoozed_until: row.snoozed_until, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: row.disposition_updated_at, + sync_status: row.sync_status, + // local_schedules 没有单独记录到达点位的列,围栏到达位置暂不持久化。 + recorded_location: null, + }, + status: row.status, + // 表里没有独立的设备端 revision 列,用 cloud_revision 兼当;这个字段在 + // reminder 领域逻辑里当前也没被读取。 + revision: row.cloud_revision, + cloud_revision: row.cloud_revision, + updated_at: row.updated_at, + }; +} diff --git a/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts new file mode 100644 index 00000000..232df5da --- /dev/null +++ b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts @@ -0,0 +1,74 @@ +import type { LocalReminderRuntimeUpdate, ScheduleLocalRepository } from '../../../schedule/data'; +import type { ReminderStateStore } from '../../application/interfaces'; +import type { ReminderDisposition, ReminderRuntimeState } from '../../domain'; + +type Target = { repository: ScheduleLocalRepository; accountId: string }; + +/** + * 提醒运行时状态(响没响、确认没确认)的真实持久化:写 SQLite `local_schedules` 的 + * 运行时字段。跟 SqliteLocalScheduleReader 一样用 attach()/detach() 延迟绑定—— + * createAppServices() 构造时账号和仓储都还没有,整个 App 生命周期只有这一个实例。 + * + * 没有这个类之前用的是 MemoryReminderStateStore(纯内存),App 进程一死状态就丢, + * 已经响过的到点提醒下次启动会被当成全新的重新弹一遍。 + */ +export class SqliteReminderStateStore implements ReminderStateStore { + private target: Target | null = null; + + public attach(repository: ScheduleLocalRepository, accountId: string): void { + this.target = { repository, accountId }; + } + + public detach(): void { + this.target = null; + } + + public async read(scheduleId: string): Promise { + if (this.target === null) return null; + const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId); + if (row === null) return null; + return { + reminder_disposition_state: row.reminder_disposition_state, + next_trigger_at: row.next_trigger_at, + snoozed_until: row.snoozed_until, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: row.disposition_updated_at, + sync_status: row.sync_status, + // local_schedules 没有单独记录到达点位的列,跟 SqliteLocalScheduleReader 一致处理。 + recorded_location: null, + }; + } + + public async write(scheduleId: string, state: ReminderRuntimeState): Promise { + if (this.target === null) return; + await this.target.repository.updateReminderRuntime( + this.target.accountId, + scheduleId, + toRuntimeUpdate(state), + ); + } + + public async setDisposition(scheduleId: string, disposition: ReminderDisposition): Promise { + if (this.target === null) return; + const current = await this.read(scheduleId); + await this.target.repository.updateReminderRuntime(this.target.accountId, scheduleId, { + reminder_disposition_state: disposition.state, + next_trigger_at: current?.next_trigger_at ?? null, + snoozed_until: disposition.snoozed_until, + geofence_armed: (current?.geofence_armed ?? false) ? 1 : 0, + disposition_updated_at: disposition.updated_at, + sync_status: disposition.sync_status, + }); + } +} + +function toRuntimeUpdate(state: ReminderRuntimeState): LocalReminderRuntimeUpdate { + return { + reminder_disposition_state: state.reminder_disposition_state, + next_trigger_at: state.next_trigger_at, + snoozed_until: state.snoozed_until, + geofence_armed: state.geofence_armed ? 1 : 0, + disposition_updated_at: state.disposition_updated_at, + sync_status: state.sync_status, + }; +} diff --git a/frontend/src/features/reminder/data/local/index.ts b/frontend/src/features/reminder/data/local/index.ts index 4f8b255e..191e728b 100644 --- a/frontend/src/features/reminder/data/local/index.ts +++ b/frontend/src/features/reminder/data/local/index.ts @@ -1,6 +1,11 @@ -export { MockLocalScheduleReader } from './MockLocalScheduleReader'; -export { MockReminderApplication } from './MockReminderApplication'; -export { MockReminderDispositionSync } from './MockReminderDispositionSync'; -export { MockReminderStateStore } from './MockReminderStateStore'; export { MemoryReminderStateStore } from './MemoryReminderStateStore'; -export { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules'; +export { InMemoryLocalScheduleReader } from './InMemoryLocalScheduleReader'; +export { SqliteLocalScheduleReader } from './SqliteLocalScheduleReader'; +export { SqliteReminderStateStore } from './SqliteReminderStateStore'; +export { + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + LocalSystemNotification, + NoopPopup, +} from './LocalReminderAdapters'; diff --git a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts b/frontend/src/features/reminder/data/local/mockReminderSchedules.ts deleted file mode 100644 index cc5de05e..00000000 --- a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { LocalReminderSchedule } from '../../domain'; - -const MOCK_RUNTIME = { - reminder_disposition_state: null, - next_trigger_at: '2026-08-07T01:00:00.000Z', - snoozed_until: null, - geofence_armed: false, - disposition_updated_at: null, - sync_status: 'synced' as const, - recorded_location: null, -}; - -/** 接入本地数据库读取器前使用的固定快照。 */ -export const MOCK_REMINDER_SCHEDULES: readonly LocalReminderSchedule[] = [ - { - id: 'mock-schedule-time-001', - account_id: 'mock-account-001', - title: '项目例会', - schedule_type: 'time', - schedule_kind: 'once', - is_all_day: false, - start_time: '2026-08-07T01:15:00.000Z', - end_time: null, - timezone: 'Asia/Shanghai', - recurrence_rule: null, - location_name: '203 会议室', - latitude: null, - longitude: null, - geofence_radius_meters: 100, - reminder: { - reminder_type: 'before_start', - reminder_trigger_at: null, - reminder_offset_minutes: 15, - reminder_strength: 'medium', - }, - runtime: { ...MOCK_RUNTIME }, - status: 'active', - revision: 1, - cloud_revision: 1, - updated_at: '2026-08-06T09:00:00.000Z', - }, - { - id: 'mock-schedule-location-001', - account_id: 'mock-account-001', - title: '取车提醒', - schedule_type: 'location', - schedule_kind: 'once', - is_all_day: false, - start_time: null, - end_time: null, - timezone: 'Asia/Shanghai', - recurrence_rule: null, - location_name: '停车场', - latitude: 31.2304, - longitude: 121.4737, - geofence_radius_meters: 100, - reminder: { - reminder_type: 'return_to_recorded_location', - reminder_trigger_at: null, - reminder_offset_minutes: null, - reminder_strength: 'high', - }, - runtime: { - ...MOCK_RUNTIME, - next_trigger_at: null, - recorded_location: { latitude: 31.2304, longitude: 121.4737 }, - }, - status: 'active', - revision: 1, - cloud_revision: 1, - updated_at: '2026-08-06T09:00:00.000Z', - }, -]; diff --git a/frontend/src/features/reminder/domain/.gitkeep b/frontend/src/features/reminder/domain/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/domain/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/domain/index.ts b/frontend/src/features/reminder/domain/index.ts index 86f87408..328cc015 100644 --- a/frontend/src/features/reminder/domain/index.ts +++ b/frontend/src/features/reminder/domain/index.ts @@ -35,3 +35,5 @@ export { resolveSnoozeUntil, resolveTimeTriggerAt, } from './timeWindow'; +export type { StrengthDeliveryPlan } from './strengthDelivery'; +export { resolveStrengthDeliveryPlan } from './strengthDelivery'; diff --git a/frontend/src/features/reminder/domain/reminder.ts b/frontend/src/features/reminder/domain/reminder.ts index c20b859d..58b4c215 100644 --- a/frontend/src/features/reminder/domain/reminder.ts +++ b/frontend/src/features/reminder/domain/reminder.ts @@ -73,12 +73,7 @@ export type LocalReminderSchedule = { }; export type ReminderTriggerReason = - | 'at_time' - | 'before_start' - | 'arrive_location' - | 'return_to_recorded_location' - | 'snooze_expired' - | 'mock'; + 'at_time' | 'before_start' | 'arrive_location' | 'return_to_recorded_location' | 'snooze_expired'; export type ReminderTrigger = { reminder_id: string; diff --git a/frontend/src/features/reminder/domain/strengthDelivery.ts b/frontend/src/features/reminder/domain/strengthDelivery.ts new file mode 100644 index 00000000..37a56ff1 --- /dev/null +++ b/frontend/src/features/reminder/domain/strengthDelivery.ts @@ -0,0 +1,35 @@ +import type { ReminderStrength } from './reminder'; + +/** 提醒强度对应的客户端送达通道组合。 */ +export type StrengthDeliveryPlan = { + useSystemNotification: boolean; + usePopup: boolean; + useVibration: boolean; + useAudio: boolean; +}; + +export function resolveStrengthDeliveryPlan(strength: ReminderStrength): StrengthDeliveryPlan { + switch (strength) { + case 'low': + return { + useSystemNotification: true, + usePopup: false, + useVibration: false, + useAudio: false, + }; + case 'medium': + return { + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: false, + }; + case 'high': + return { + useSystemNotification: false, + usePopup: true, + useVibration: true, + useAudio: true, + }; + } +} diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index 5548f3b5..3b376418 100644 --- a/frontend/src/features/reminder/index.ts +++ b/frontend/src/features/reminder/index.ts @@ -18,9 +18,27 @@ export type { ReminderTrigger, ReminderTriggerReason, ReminderType, + GeofenceTransition, + GeofenceWatchMode, + StrengthDeliveryPlan, +} from './domain'; +export { + DEFAULT_SNOOZE_MINUTES, + distanceMeters, + evaluateGeofence, + isSnoozeActive, + isSnoozeExpired, + isTimeWindowReached, + resolveEffectiveTriggerAt, + resolveGeofenceCenter, + resolveSnoozeUntil, + resolveStrengthDeliveryPlan, + resolveTimeTriggerAt, + resolveWatchMode, } from './domain'; -export { DEFAULT_SNOOZE_MINUTES } from './domain'; export type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -34,9 +52,13 @@ export type { LocalTimeTick, LocationMonitorEvent, LocationMonitorPort, + LocationRebuildTarget, LocationWatchHandle, LocationWatchMode, LocationWatchRequest, + AlertDialogButton, + AlertDialogPort, + AlertDialogRequest, PopupPort, PopupReceipt, PopupRequest, @@ -64,11 +86,15 @@ export type { } from './application'; export { LocalReminderApplication } from './application'; export { - MockLocalScheduleReader, - MockReminderApplication, - MockReminderDispositionSync, - MockReminderStateStore, - MOCK_REMINDER_SCHEDULES, MemoryReminderStateStore, + InMemoryLocalScheduleReader, + SqliteLocalScheduleReader, + SqliteReminderStateStore, + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + LocalSystemNotification, + NoopPopup, } from './data/local'; -export { MockReminderPresenter, useReminderPermissionsOnLaunch } from './presentation'; +export { AlertReminderPresenter, useReminderPermissionsOnLaunch } from './presentation'; +export type { ReminderActionHandler, ReminderViewModel } from './presentation'; diff --git a/frontend/src/features/reminder/presentation/.gitkeep b/frontend/src/features/reminder/presentation/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/features/reminder/presentation/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts new file mode 100644 index 00000000..a35306e9 --- /dev/null +++ b/frontend/src/features/reminder/presentation/AlertReminderPresenter.ts @@ -0,0 +1,76 @@ +import type { + ReminderPresentationAction, + ReminderPresentationReceipt, + ReminderPresenterPort, + AlertDialogPort, +} from '../application/interfaces'; +import type { ReminderDeliveryRequest } from '../domain'; + +const MESSAGE_BY_REASON: Record = { + arrive_location: '您已进入目标地点附近,请及时处理。', + return_to_recorded_location: '您已回到记录地点附近,请及时处理。', + at_time: '已到提醒时间,请及时处理。', + before_start: '日程即将开始,请及时处理。', + snooze_expired: '延后提醒时间已到,请及时处理。', +}; + +/** 提醒展示编排;平台 Alert 由注入的 AlertDialogPort 完成。 */ +export class AlertReminderPresenter implements ReminderPresenterPort { + private readonly actionListeners = new Set< + (event: { schedule_id: string; action: ReminderPresentationAction }) => void + >(); + private visibleScheduleId: string | null = null; + private readonly suppressed = new Set(); + + constructor(private readonly dialog: AlertDialogPort) {} + + async show(request: ReminderDeliveryRequest): Promise { + this.suppressed.delete(request.schedule_id); + this.visibleScheduleId = request.schedule_id; + const message = MESSAGE_BY_REASON[request.trigger.reason] ?? '日程提醒已触发,请及时处理。'; + + await this.dialog.show({ + title: request.title || '日程提醒', + message, + buttons: [ + { + text: '延后', + style: 'cancel', + onPress: () => this.emit(request.schedule_id, 'snooze'), + }, + { + text: '确认', + onPress: () => this.emit(request.schedule_id, 'confirm'), + }, + ], + }); + + return { + presentation_id: `alert-${request.schedule_id}`, + visible: true, + }; + } + + async hide(scheduleId: string): Promise { + this.suppressed.add(scheduleId); + if (this.visibleScheduleId === scheduleId) { + this.visibleScheduleId = null; + } + } + + onAction( + listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void, + ): () => void { + this.actionListeners.add(listener); + return () => { + this.actionListeners.delete(listener); + }; + } + + private emit(scheduleId: string, action: ReminderPresentationAction): void { + if (this.suppressed.has(scheduleId)) return; + for (const listener of this.actionListeners) { + listener({ schedule_id: scheduleId, action }); + } + } +} diff --git a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts b/frontend/src/features/reminder/presentation/MockReminderPresenter.ts deleted file mode 100644 index 34bc0fc5..00000000 --- a/frontend/src/features/reminder/presentation/MockReminderPresenter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - ReminderPresenterPort, - ReminderPresentationAction, - ReminderPresentationReceipt, -} from '../application/interfaces'; -import type { ReminderDeliveryRequest } from '../domain'; - -/** 应用级弹窗/悬浮层完成前使用的固定展示器。 */ -export class MockReminderPresenter implements ReminderPresenterPort { - async show(_request: ReminderDeliveryRequest): Promise { - return { - presentation_id: 'mock-presentation-001', - visible: true, - }; - } - - async hide(_scheduleId: string): Promise { - return Promise.resolve(); - } - - onAction( - _listener: (event: { schedule_id: string; action: ReminderPresentationAction }) => void, - ): () => void { - return () => undefined; - } -} diff --git a/frontend/src/features/reminder/presentation/index.ts b/frontend/src/features/reminder/presentation/index.ts index 90ea79ea..bd6e4fdc 100644 --- a/frontend/src/features/reminder/presentation/index.ts +++ b/frontend/src/features/reminder/presentation/index.ts @@ -1,3 +1,3 @@ -export { MockReminderPresenter } from './MockReminderPresenter'; +export { AlertReminderPresenter } from './AlertReminderPresenter'; export { useReminderPermissionsOnLaunch } from './useReminderPermissionsOnLaunch'; export type { ReminderActionHandler, ReminderViewModel } from './ReminderViewModel'; diff --git a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts index a62a036d..ca37781d 100644 --- a/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts +++ b/frontend/src/features/reminder/presentation/useReminderPermissionsOnLaunch.ts @@ -1,9 +1,12 @@ import { useEffect, useRef } from 'react'; -import { Alert, AppState, type AppStateStatus, Platform } from 'react-native'; -import type { DeviceCapabilityPort, DevicePermission } from '../application/interfaces'; +import type { + AlertDialogPort, + DeviceCapabilityPort, + DevicePermission, +} from '../application/interfaces'; -const PERMISSION_ORDER: DevicePermission[] = [ +const ANDROID_ALARM_ORDER: DevicePermission[] = [ 'notifications', 'exact_alarm', 'overlay', @@ -11,6 +14,8 @@ const PERMISSION_ORDER: DevicePermission[] = [ 'battery_optimization', ]; +const LOCATION_ORDER: DevicePermission[] = ['location_foreground', 'location_background']; + const PERMISSION_PROMPTS: Partial> = { notifications: { title: '需要通知权限', @@ -32,31 +37,49 @@ const PERMISSION_PROMPTS: Partial = new Set(['notifications']); + +/** 精确闹钟没有系统授权框,只能跳设置页;先弹说明框会多一步点击,直接跳过去。 */ +const DIRECT_SETTINGS: ReadonlySet = new Set(['exact_alarm']); + +const LOCATION_PERMISSIONS: ReadonlySet = new Set([ + 'location_foreground', + 'location_background', +]); + /** - * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort 访问平台, - * 不在 UI 层直接依赖 NativeModules。 + * 启动时逐项申请提醒相关权限;通过 DeviceCapabilityPort / AlertDialogPort 访问平台, + * 不在 UI 层直接依赖 react-native。 + * + * - Android(alarm 能力可用):闹钟相关权限 + 定位权限 + * - 其它平台 / alarm 不可用:仍申请定位权限,保证地点提醒链路可授权 + * + * @param onPermissionsUpdated 某项权限刚授权成功时回调(用于重建围栏/闹钟)。 */ -export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | null): void { +export function useReminderPermissionsOnLaunch( + device: DeviceCapabilityPort | null, + dialog: AlertDialogPort | null, + onPermissionsUpdated?: () => void, +): void { const busyRef = useRef(false); const awaitingReturnRef = useRef(false); const skippedRef = useRef(new Set()); useEffect(() => { - if (Platform.OS !== 'android' || device == null) return; + if (device == null || dialog == null) return; let cancelled = false; - const timeouts = new Set>(); - - const schedule = (fn: () => void, ms: number) => { - const id = setTimeout(() => { - timeouts.delete(id); - if (cancelled) return; - fn(); - }, ms); - timeouts.add(id); - }; const runPrompt = () => { if (cancelled) return; @@ -66,90 +89,127 @@ export function useReminderPermissionsOnLaunch(device: DeviceCapabilityPort | nu }; const promptNext = async () => { - if (cancelled || busyRef.current) return; + if (busyRef.current || cancelled || awaitingReturnRef.current) return; busyRef.current = true; + let queueNext = false; try { const status = await device.getStatus(); - if (cancelled) return; - if (!status.supported) return; + if (status.platform === 'web' || status.platform === 'unknown') return; - const missing = PERMISSION_ORDER.find((permission) => { - if (skippedRef.current.has(permission)) return false; - return !status.permissions[permission]; - }); + const missing = nextMissingPermission(status); if (missing == null) return; const prompt = PERMISSION_PROMPTS[missing]; if (prompt == null) { skippedRef.current.add(missing); + queueNext = true; return; } - if (missing === 'notifications') { - let granted = false; - try { - granted = await device.requestPermission('notifications'); - } catch { - granted = false; + // 通知:直接系统授权框。 + if (DIRECT_REQUEST.has(missing)) { + const granted = await device.requestPermission(missing); + if (!granted) { + skippedRef.current.add(missing); + } else { + onPermissionsUpdated?.(); + } + queueNext = true; + return; + } + + // 精确闹钟没有系统授权框,只能跳设置页,先弹说明框只会多一次点击,直接跳过去。 + if (DIRECT_SETTINGS.has(missing)) { + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; + skippedRef.current.add(missing); + queueNext = true; } - if (cancelled) return; - if (!granted) skippedRef.current.add('notifications'); - busyRef.current = false; - schedule(runPrompt, 350); return; } - const shouldAuthorize = await confirmAsync(prompt.title, prompt.message); - if (cancelled) return; + const shouldAuthorize = await confirmAsync(dialog, prompt.title, prompt.message); if (!shouldAuthorize) { skippedRef.current.add(missing); - } else { - let opened = false; - try { - opened = await device.openSettings(missing); - } catch { - opened = false; + queueNext = true; + return; + } + + // 定位:先等应用内说明框关掉,再申请系统权限;失败则跳转设置,避免“点了没反应还连弹”。 + if (LOCATION_PERMISSIONS.has(missing)) { + await delay(350); + const granted = await device.requestPermission(missing); + if (granted) { + onPermissionsUpdated?.(); + queueNext = true; + return; } - if (cancelled) return; - if (opened) { - awaitingReturnRef.current = true; - } else { + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; skippedRef.current.add(missing); + queueNext = true; } + return; + } + + // 精确闹钟 / 悬浮窗等:跳转系统设置,回来后再继续。 + awaitingReturnRef.current = true; + const opened = await device.openSettings(missing); + if (!opened) { + awaitingReturnRef.current = false; + skippedRef.current.add(missing); + queueNext = true; } } finally { busyRef.current = false; } - if (!cancelled && !awaitingReturnRef.current) { - schedule(runPrompt, 200); + if (queueNext && !awaitingReturnRef.current) { + setTimeout(runPrompt, 250); } }; - const onAppStateChange = (state: AppStateStatus) => { - if (cancelled) return; - if (state !== 'active') return; + const nextMissingPermission = ( + status: Awaited>, + ): DevicePermission | null => { + if (status.platform === 'android' && status.supported) { + const alarmMissing = ANDROID_ALARM_ORDER.find((permission) => { + if (skippedRef.current.has(permission)) return false; + return !status.permissions[permission]; + }); + if (alarmMissing != null) return alarmMissing; + } + + return ( + LOCATION_ORDER.find((permission) => { + if (skippedRef.current.has(permission)) return false; + return !status.permissions[permission]; + }) ?? null + ); + }; + + const unsubscribe = device.onAppActive(() => { if (!awaitingReturnRef.current) return; awaitingReturnRef.current = false; - schedule(runPrompt, 300); - }; + onPermissionsUpdated?.(); + setTimeout(runPrompt, 300); + }); - schedule(runPrompt, 600); - const subscription = AppState.addEventListener('change', onAppStateChange); + const timer = setTimeout(runPrompt, 600); return () => { cancelled = true; - awaitingReturnRef.current = false; - for (const id of timeouts) { - clearTimeout(id); - } - timeouts.clear(); - subscription.remove(); + clearTimeout(timer); + unsubscribe(); }; - }, [device]); + }, [device, dialog, onPermissionsUpdated]); } -function confirmAsync(title: string, message: string): Promise { +function confirmAsync(dialog: AlertDialogPort, title: string, message: string): Promise { return new Promise((resolve) => { let settled = false; const finish = (value: boolean) => { @@ -157,14 +217,19 @@ function confirmAsync(title: string, message: string): Promise { settled = true; resolve(value); }; - Alert.alert( + void dialog.show({ title, message, - [ + cancelable: true, + onDismiss: () => finish(false), + buttons: [ { text: '暂不', style: 'cancel', onPress: () => finish(false) }, { text: '去授权', onPress: () => finish(true) }, ], - { cancelable: true, onDismiss: () => finish(false) }, - ); + }); }); } + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/src/infrastructure/audio/.gitkeep b/frontend/src/infrastructure/audio/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/audio/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts new file mode 100644 index 00000000..548922e2 --- /dev/null +++ b/frontend/src/infrastructure/audio/ExpoAudioPlayback.ts @@ -0,0 +1,147 @@ +import type { + AudioPlaybackPort, + AudioPlaybackReceipt, + AudioPlaybackRequest, +} from '../../features/reminder/application/interfaces'; +import { buildAudioDataUri } from './audioDataUri'; + +// Metro 资源 id;高强度本地兜底铃声(与原生 AlarmSoundService 同源)。 +const LOCAL_ALARM_SOUND = require('../../../assets/sounds/alarm_prompt.mp3') as number; + +type AudioPlayerLike = { + pause: () => void; + replace: (source: string | number) => void; + play: () => void; + volume: number; + loop: boolean; +}; + +type ExpoAudioModule = { + createAudioPlayer: (source?: string | number | null) => AudioPlayerLike; + setAudioModeAsync: (mode: Record) => Promise; +}; + +/** + * 音频播放适配器: + * - TTS 有字节时播放并循环,直到 stop + * - 否则播放打包的 alarm_prompt.mp3(高强度本地兜底) + */ +export class ExpoAudioPlayback implements AudioPlaybackPort { + private player: AudioPlayerLike | null = null; + private activeScheduleId: string | null = null; + private modeReady: Promise | null = null; + + async isTtsAvailable(): Promise { + // TTS 字节管线尚未接入;有 data 时由 playTts 直接播放。 + return false; + } + + async playTts(request: AudioPlaybackRequest): Promise { + if (request.data == null || request.data.byteLength === 0) { + return { + playback_id: `tts-empty-${request.schedule_id}`, + played: false, + used_local_fallback: false, + }; + } + const played = await this.playBytes(request.schedule_id, request.data, request.format ?? 'wav'); + return { + playback_id: `tts-${request.schedule_id}`, + played, + used_local_fallback: false, + }; + } + + async playLocalFallback(request: AudioPlaybackRequest): Promise { + if (request.data != null && request.data.byteLength > 0) { + const played = await this.playBytes( + request.schedule_id, + request.data, + request.format ?? 'wav', + ); + return { + playback_id: `local-${request.schedule_id}`, + played, + used_local_fallback: true, + }; + } + + const played = await this.playBundledAlarm(request.schedule_id); + return { + playback_id: `local-bundled-${request.schedule_id}`, + played, + used_local_fallback: true, + }; + } + + async stop(scheduleId: string): Promise { + if (this.activeScheduleId !== scheduleId) return; + if (this.player != null) { + this.player.loop = false; + this.player.pause(); + } + this.activeScheduleId = null; + } + + private async playBytes(scheduleId: string, data: Uint8Array, format: string): Promise { + const expoAudio = await loadExpoAudio(); + if (expoAudio == null) return false; + + await this.ensureAudioMode(expoAudio); + const player = this.ensurePlayer(expoAudio); + player.pause(); + player.replace(buildAudioDataUri(data, format)); + player.loop = true; + player.volume = 1; + this.activeScheduleId = scheduleId; + player.play(); + return true; + } + + private async playBundledAlarm(scheduleId: string): Promise { + const expoAudio = await loadExpoAudio(); + if (expoAudio == null) return false; + + await this.ensureAudioMode(expoAudio); + const player = this.ensurePlayer(expoAudio); + player.pause(); + player.replace(LOCAL_ALARM_SOUND); + player.loop = true; + player.volume = 1; + this.activeScheduleId = scheduleId; + player.play(); + return true; + } + + private ensurePlayer(expoAudio: ExpoAudioModule): AudioPlayerLike { + if (this.player == null) { + this.player = expoAudio.createAudioPlayer(null); + } + return this.player; + } + + private async ensureAudioMode(expoAudio: ExpoAudioModule): Promise { + if (this.modeReady == null) { + this.modeReady = expoAudio + .setAudioModeAsync({ + allowsRecording: false, + interruptionMode: 'doNotMix', + playsInSilentMode: true, + shouldPlayInBackground: true, + shouldRouteThroughEarpiece: false, + }) + .catch(() => undefined); + } + await this.modeReady; + } +} + +async function loadExpoAudio(): Promise { + try { + const mod = await import('expo-audio'); + if (typeof mod.createAudioPlayer !== 'function') return null; + return mod as unknown as ExpoAudioModule; + } catch { + return null; + } +} diff --git a/frontend/src/infrastructure/audio/MockAudioPlayback.ts b/frontend/src/infrastructure/audio/MockAudioPlayback.ts deleted file mode 100644 index f59131bc..00000000 --- a/frontend/src/infrastructure/audio/MockAudioPlayback.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { - AudioPlaybackPort, - AudioPlaybackReceipt, - AudioPlaybackRequest, -} from '../../features/reminder/application/interfaces'; - -/** 固定音频能力:远程语音合成不可用,本地兜底音效可用。 */ -export class MockAudioPlayback implements AudioPlaybackPort { - async isTtsAvailable(): Promise { - return false; - } - - async playTts(_request: AudioPlaybackRequest): Promise { - return { - playback_id: 'mock-tts-playback-001', - played: false, - used_local_fallback: false, - }; - } - - async playLocalFallback(_request: AudioPlaybackRequest): Promise { - return { - playback_id: 'mock-local-sound-001', - played: true, - used_local_fallback: true, - }; - } - - async stop(_scheduleId: string): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/infrastructure/audio/audioDataUri.ts b/frontend/src/infrastructure/audio/audioDataUri.ts new file mode 100644 index 00000000..94976c5d --- /dev/null +++ b/frontend/src/infrastructure/audio/audioDataUri.ts @@ -0,0 +1,39 @@ +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const MIME_BY_FORMAT: Record = { + aac: 'audio/aac', + m4a: 'audio/mp4', + mp3: 'audio/mpeg', + mpeg: 'audio/mpeg', + oga: 'audio/ogg', + ogg: 'audio/ogg', + wav: 'audio/wav', + wave: 'audio/wav', +}; + +export function encodeBase64(bytes: Uint8Array): string { + let output = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const hasSecond = index + 1 < bytes.length; + const hasThird = index + 2 < bytes.length; + const second = hasSecond ? bytes[index + 1]! : 0; + const third = hasThird ? bytes[index + 2]! : 0; + const value = (first << 16) | (second << 8) | third; + + output += BASE64_ALPHABET[(value >>> 18) & 63]; + output += BASE64_ALPHABET[(value >>> 12) & 63]; + output += hasSecond ? BASE64_ALPHABET[(value >>> 6) & 63] : '='; + output += hasThird ? BASE64_ALPHABET[value & 63] : '='; + } + return output; +} + +export function buildAudioDataUri(bytes: Uint8Array, audioFormat: string): string { + const normalizedFormat = audioFormat.trim().toLowerCase().replace(/^\./, ''); + if (!/^[a-z0-9][a-z0-9.+-]{0,31}$/.test(normalizedFormat)) { + throw new Error('Unsupported reminder audio format'); + } + const mime = MIME_BY_FORMAT[normalizedFormat] ?? `audio/${normalizedFormat}`; + return `data:${mime};base64,${encodeBase64(bytes)}`; +} diff --git a/frontend/src/infrastructure/audio/index.ts b/frontend/src/infrastructure/audio/index.ts index 8cc9460f..48529eb5 100644 --- a/frontend/src/infrastructure/audio/index.ts +++ b/frontend/src/infrastructure/audio/index.ts @@ -1 +1,2 @@ -export { MockAudioPlayback } from './MockAudioPlayback'; +export { ExpoAudioPlayback } from './ExpoAudioPlayback'; +export { buildAudioDataUri, encodeBase64 } from './audioDataUri'; diff --git a/frontend/src/infrastructure/location/.gitkeep b/frontend/src/infrastructure/location/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/location/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts new file mode 100644 index 00000000..cae3847f --- /dev/null +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -0,0 +1,225 @@ +import * as Location from 'expo-location'; +import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationMonitorPort, + LocationRebuildTarget, + LocationWatchHandle, + LocationWatchRequest, +} from '../../features/reminder/application/interfaces'; +import type { LocationSample } from '../../features/reminder/domain'; + +import { + GEOFENCE_TASK_NAME, + subscribeGeofenceTaskEvents, + type GeofenceTaskPayload, +} from './geofenceTask'; +import type { LocationProvider } from './LocationProvider'; + +type ActiveWatch = { + listener_id: string; + request: LocationWatchRequest; + listener: (event: LocationMonitorEvent) => void; +}; + +/** ≈1.1km,足够超出任何合理的围栏半径,用来给 exit 事件合成一个"明显在圈外"的采样点。 */ +const OUTSIDE_OFFSET_DEGREES = 0.01; + +/** + * 基于 expo-location 系统原生地理围栏(Android GeofencingClient / iOS + * CLCircularRegion)的适配器:不依赖百度账号/Key,围栏进出判断交给系统,比 + * NativeLocationMonitor 那套连续定位轮询 + 应用侧 Haversine 更省电、后台也更可靠。 + * + * 系统只会在真正穿越边界时回调 enter/exit,不会在注册那一刻告诉你当前在圈内还是圈 + * 外,所以 watch() 里仍然主动取一次当前定位、强制送一条初始采样,让 + * LocalReminderApplication 的 armed 状态机能定出正确的起始值;之后的进出全靠 + * geofenceTask 的系统回调,不再轮询。 + * + * LocalReminderApplication.applyLocationSample() 不看这里传的 phase,只用 sample + * 坐标自己重新跑一遍 Haversine 判断,所以 enter/exit 时合成的坐标(区域中心 / + * 中心外一段距离)足够,不需要为了拿"真实"坐标再多发一次定位请求。 + */ +export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvider { + private readonly watches = new Map(); + private readonly scheduleToListener = new Map(); + private lastSample: LocationSample | null = null; + private syncChain: Promise = Promise.resolve(); + private unsubscribeTask: (() => void) | null; + private readonly appStateSub: NativeEventSubscription; + + constructor() { + this.unsubscribeTask = subscribeGeofenceTaskEvents(this.handleTaskEvent); + this.appStateSub = AppState.addEventListener('change', this.handleAppState); + } + + async watch( + request: LocationWatchRequest, + listener: (event: LocationMonitorEvent) => void, + ): Promise { + const existingId = this.scheduleToListener.get(request.schedule_id); + if (existingId != null) { + await this.removeWatch(existingId, false); + } + + const listener_id = `location-${request.schedule_id}`; + this.watches.set(listener_id, { listener_id, request: { ...request }, listener }); + this.scheduleToListener.set(request.schedule_id, listener_id); + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + listener({ schedule_id: request.schedule_id, sample, phase: 'inside' }); + } + + return { listener_id, schedule_id: request.schedule_id }; + } + + async unwatch(listenerId: string): Promise { + await this.removeWatch(listenerId, true); + } + + async rebuild( + targets: readonly LocationRebuildTarget[], + listener: (event: LocationMonitorEvent) => void, + ): Promise { + for (const listenerId of [...this.watches.keys()]) { + await this.removeWatch(listenerId, false); + } + await this.enqueueSync(); + + const handles: LocationWatchHandle[] = []; + for (const target of targets) { + handles.push( + await this.watch( + { + schedule_id: target.schedule_id, + center: target.center, + radius_meters: target.radius_meters, + mode: target.mode, + background: target.background, + }, + listener, + ), + ); + } + return handles; + } + + async getLastSample(): Promise { + return this.lastSample; + } + + async getCurrentSample(): Promise { + try { + const { status } = await Location.getForegroundPermissionsAsync(); + if (status !== 'granted') return this.lastSample; + const position = await Location.getCurrentPositionAsync({}); + const sample = toSample(position); + this.lastSample = sample; + return sample; + } catch { + return this.lastSample; + } + } + + dispose(): void { + this.unsubscribeTask?.(); + this.unsubscribeTask = null; + this.appStateSub.remove(); + void Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME) + .then((started) => (started ? Location.stopGeofencingAsync(GEOFENCE_TASK_NAME) : undefined)) + .catch(() => undefined); + } + + private readonly handleAppState = (state: AppStateStatus): void => { + if (state !== 'active' || this.watches.size === 0) return; + void this.enqueueSync(); + }; + + private readonly handleTaskEvent = (payload: GeofenceTaskPayload): void => { + const listenerId = this.scheduleToListener.get(payload.schedule_id); + const watch = listenerId != null ? this.watches.get(listenerId) : undefined; + if (watch == null) return; + + const sample: LocationSample = + payload.event === 'enter' + ? { + latitude: payload.latitude, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + } + : { + latitude: payload.latitude + OUTSIDE_OFFSET_DEGREES, + longitude: payload.longitude, + accuracy_meters: payload.radius, + observed_at: payload.observed_at, + }; + this.lastSample = sample; + watch.listener({ + schedule_id: watch.request.schedule_id, + sample, + phase: payload.event === 'enter' ? 'entered' : 'left', + }); + }; + + private async removeWatch(listenerId: string, sync: boolean): Promise { + const watch = this.watches.get(listenerId); + if (watch == null) return; + this.watches.delete(listenerId); + this.scheduleToListener.delete(watch.request.schedule_id); + if (sync) { + await this.enqueueSync(); + } + } + + private enqueueSync(): Promise { + this.syncChain = this.syncChain.then( + () => this.syncRegions(), + () => this.syncRegions(), + ); + return this.syncChain; + } + + private async syncRegions(): Promise { + if (this.watches.size === 0) { + const started = await Location.hasStartedGeofencingAsync(GEOFENCE_TASK_NAME).catch( + () => false, + ); + if (started) { + await Location.stopGeofencingAsync(GEOFENCE_TASK_NAME).catch(() => undefined); + } + return; + } + + const { status: foreground } = await Location.getForegroundPermissionsAsync(); + if (foreground !== 'granted') return; + const { status: background } = await Location.getBackgroundPermissionsAsync(); + if (background !== 'granted') return; + + const regions: Location.LocationRegion[] = [...this.watches.values()].map((watch) => ({ + identifier: watch.request.schedule_id, + latitude: watch.request.center.latitude, + longitude: watch.request.center.longitude, + radius: watch.request.radius_meters, + notifyOnEnter: true, + notifyOnExit: true, + })); + + try { + await Location.startGeofencingAsync(GEOFENCE_TASK_NAME, regions); + } catch { + // 系统围栏注册失败(比如权限被收回);下次 watch()/rebuild() 会再重试。 + } + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy_meters: position.coords.accuracy ?? 0, + observed_at: new Date(position.timestamp).toISOString(), + }; +} diff --git a/frontend/src/infrastructure/location/LocationProvider.ts b/frontend/src/infrastructure/location/LocationProvider.ts index 84933700..723c463a 100644 --- a/frontend/src/infrastructure/location/LocationProvider.ts +++ b/frontend/src/infrastructure/location/LocationProvider.ts @@ -1,6 +1,6 @@ -import type { LocationSample } from '../../features/reminder/domain'; +import type { LocationObservation } from '../../contracts/reminder'; /** 获取一次当前位置样本的平台适配器。 */ export interface LocationProvider { - getCurrentSample(): Promise; + getCurrentSample(): Promise; } diff --git a/frontend/src/infrastructure/location/MockLocationMonitor.ts b/frontend/src/infrastructure/location/MockLocationMonitor.ts deleted file mode 100644 index b5fb4e65..00000000 --- a/frontend/src/infrastructure/location/MockLocationMonitor.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { - LocationMonitorEvent, - LocationMonitorPort, - LocationWatchHandle, - LocationWatchRequest, -} from '../../features/reminder/application/interfaces'; -import type { LocalReminderSchedule, LocationSample } from '../../features/reminder/domain'; - -import type { LocationProvider } from './LocationProvider'; - -const MOCK_SAMPLE: LocationSample = { - latitude: 31.2304, - longitude: 121.4737, - accuracy_meters: 12, - observed_at: '2026-08-07T01:00:00.000Z', -}; - -function isLocationSchedule(schedule: LocalReminderSchedule): boolean { - return schedule.schedule_type === 'location' && schedule.status === 'active'; -} - -/** 固定定位能力适配器,不访问平台定位接口。 */ -export class MockLocationMonitor implements LocationMonitorPort, LocationProvider { - async watch( - request: LocationWatchRequest, - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return { - listener_id: `mock-location-listener-${request.schedule_id}`, - schedule_id: request.schedule_id, - }; - } - - async unwatch(_listenerId: string): Promise { - return Promise.resolve(); - } - - async rebuild( - schedules: readonly LocalReminderSchedule[], - _listener: (event: LocationMonitorEvent) => void, - ): Promise { - return schedules.filter(isLocationSchedule).map((schedule) => ({ - listener_id: `mock-location-listener-${schedule.id}`, - schedule_id: schedule.id, - })); - } - - async getLastSample(): Promise { - return { ...MOCK_SAMPLE }; - } - - async getCurrentSample(): Promise { - return { ...MOCK_SAMPLE }; - } -} - -export { MOCK_SAMPLE as MOCK_LOCATION_SAMPLE }; diff --git a/frontend/src/infrastructure/location/NativeLocationMonitor.ts b/frontend/src/infrastructure/location/NativeLocationMonitor.ts new file mode 100644 index 00000000..323fc535 --- /dev/null +++ b/frontend/src/infrastructure/location/NativeLocationMonitor.ts @@ -0,0 +1,223 @@ +import { AppState, type AppStateStatus, type NativeEventSubscription } from 'react-native'; + +import type { + LocationMonitorEvent, + LocationMonitorPort, + LocationRebuildTarget, + LocationWatchHandle, + LocationWatchRequest, +} from '../../features/reminder/application/interfaces'; +import type { LocationSample } from '../../features/reminder/domain'; +import { distanceMeters } from '../../features/reminder/domain/geofence'; +import type { LocationObservation } from '../../contracts/reminder'; + +import type { LocationProvider } from './LocationProvider'; +import { + baiduGetCurrentPosition, + baiduInit, + baiduStartUpdating, + baiduStopUpdating, + isBaiduLocationAvailable, + subscribeBaiduLocation, + type BaiduLocationSample, +} from './native/BaiduLocationBridge'; + +type ActiveWatch = { + listener_id: string; + request: LocationWatchRequest; + listener: (event: LocationMonitorEvent) => void; + inside: boolean | null; +}; + +/** + * 基于百度定位 SDK 的围栏/定位适配器: + * - 连续定位采样(非 Google Geofencing) + * - 应用侧 Haversine 计算进出圈边沿 + */ +export class NativeLocationMonitor implements LocationMonitorPort, LocationProvider { + private readonly watches = new Map(); + private readonly scheduleToListener = new Map(); + private lastSample: LocationSample | null = null; + private syncChain: Promise = Promise.resolve(); + private unsubscribeLocation: (() => void) | null = null; + private started = false; + private readonly appStateSub: NativeEventSubscription; + + constructor() { + this.appStateSub = AppState.addEventListener('change', this.handleAppState); + } + + async watch( + request: LocationWatchRequest, + listener: (event: LocationMonitorEvent) => void, + ): Promise { + const existingId = this.scheduleToListener.get(request.schedule_id); + if (existingId != null) { + await this.removeWatch(existingId, false); + } + + const listener_id = `location-${request.schedule_id}`; + this.watches.set(listener_id, { + listener_id, + request: { ...request }, + listener, + inside: null, + }); + this.scheduleToListener.set(request.schedule_id, listener_id); + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + const watch = this.watches.get(listener_id); + if (sample != null && watch != null) { + this.emitForWatch(watch, sample, /*forcePhase*/ true); + } + + return { listener_id, schedule_id: request.schedule_id }; + } + + async unwatch(listenerId: string): Promise { + await this.removeWatch(listenerId, true); + } + + async rebuild( + targets: readonly LocationRebuildTarget[], + listener: (event: LocationMonitorEvent) => void, + ): Promise { + for (const listenerId of [...this.watches.keys()]) { + await this.removeWatch(listenerId, false); + } + await this.enqueueSync(); + + const handles: LocationWatchHandle[] = []; + for (const target of targets) { + handles.push( + await this.watch( + { + schedule_id: target.schedule_id, + center: target.center, + radius_meters: target.radius_meters, + mode: target.mode, + background: target.background, + }, + listener, + ), + ); + } + return handles; + } + + async getLastSample(): Promise { + return this.lastSample; + } + + async getCurrentSample(): Promise { + if (!isBaiduLocationAvailable()) return this.lastSample; + try { + if (!this.started) { + await baiduInit(null); + } + const current = await baiduGetCurrentPosition(); + if (current == null) return this.lastSample; + const sample = toSample(current); + this.lastSample = sample; + return sample; + } catch { + return this.lastSample; + } + } + + dispose(): void { + this.unsubscribeLocation?.(); + this.unsubscribeLocation = null; + this.appStateSub.remove(); + void baiduStopUpdating(); + this.started = false; + } + + private readonly handleAppState = (state: AppStateStatus): void => { + if (state !== 'active' || this.watches.size === 0) return; + void this.enqueueSync(); + }; + + private async removeWatch(listenerId: string, sync: boolean): Promise { + const watch = this.watches.get(listenerId); + if (watch == null) return; + this.watches.delete(listenerId); + this.scheduleToListener.delete(watch.request.schedule_id); + if (sync) { + await this.enqueueSync(); + } + } + + private enqueueSync(): Promise { + this.syncChain = this.syncChain.then( + () => this.syncMonitoring(), + () => this.syncMonitoring(), + ); + return this.syncChain; + } + + private async syncMonitoring(): Promise { + if (this.watches.size === 0) { + this.unsubscribeLocation?.(); + this.unsubscribeLocation = null; + if (this.started) { + await baiduStopUpdating(); + this.started = false; + } + return; + } + + if (!isBaiduLocationAvailable()) { + return; + } + + if (this.unsubscribeLocation == null) { + this.unsubscribeLocation = subscribeBaiduLocation((payload) => { + const sample = toSample(payload); + this.lastSample = sample; + for (const watch of this.watches.values()) { + this.emitForWatch(watch, sample, false); + } + }); + } + + await baiduInit(null); + await baiduStartUpdating(5_000); + this.started = true; + } + + private emitForWatch(watch: ActiveWatch, sample: LocationSample, forcePhase: boolean): void { + const inside = distanceMeters(watch.request.center, sample) <= watch.request.radius_meters; + const previous = watch.inside; + watch.inside = inside; + + let phase: LocationMonitorEvent['phase']; + if (previous == null || forcePhase) { + phase = inside ? 'inside' : 'outside'; + } else if (previous === inside) { + phase = inside ? 'inside' : 'outside'; + } else { + phase = inside ? 'entered' : 'left'; + } + + if (!forcePhase && previous === inside) { + return; + } + + watch.listener({ + schedule_id: watch.request.schedule_id, + sample, + phase, + }); + } +} + +function toSample(payload: BaiduLocationSample): LocationSample { + return { + latitude: payload.latitude, + longitude: payload.longitude, + accuracy_meters: payload.accuracy ?? 0, + observed_at: payload.observedAt || new Date().toISOString(), + }; +} diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts new file mode 100644 index 00000000..55cffa10 --- /dev/null +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -0,0 +1,64 @@ +import * as Location from 'expo-location'; +import * as TaskManager from 'expo-task-manager'; + +export const GEOFENCE_TASK_NAME = 'timeflow-geofence'; + +export type GeofenceTaskPayload = { + schedule_id: string; + event: 'enter' | 'exit'; + latitude: number; + longitude: number; + radius: number; + observed_at: string; +}; + +type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void; + +const listeners = new Set(); + +/** 订阅系统围栏任务回调;须在应用入口尽早 import 本模块以完成 defineTask。 */ +export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function emit(payload: GeofenceTaskPayload): void { + for (const listener of listeners) { + listener(payload); + } +} + +if (!TaskManager.isTaskDefined(GEOFENCE_TASK_NAME)) { + TaskManager.defineTask(GEOFENCE_TASK_NAME, async ({ data, error }) => { + if (error) return; + + const payload = data as + | { + eventType?: Location.GeofencingEventType; + region?: Location.LocationRegion; + } + | undefined; + const region = payload?.region; + if (region?.identifier == null) return; + + const eventType = payload?.eventType; + const event = + eventType === Location.GeofencingEventType.Enter + ? 'enter' + : eventType === Location.GeofencingEventType.Exit + ? 'exit' + : null; + if (event == null) return; + + emit({ + schedule_id: region.identifier, + event, + latitude: region.latitude, + longitude: region.longitude, + radius: region.radius, + observed_at: new Date().toISOString(), + }); + }); +} diff --git a/frontend/src/infrastructure/location/index.ts b/frontend/src/infrastructure/location/index.ts index ef2d2b9d..1461aae2 100644 --- a/frontend/src/infrastructure/location/index.ts +++ b/frontend/src/infrastructure/location/index.ts @@ -1,2 +1,10 @@ -export { MockLocationMonitor, MOCK_LOCATION_SAMPLE } from './MockLocationMonitor'; +export { NativeLocationMonitor } from './NativeLocationMonitor'; +export { ExpoLocationMonitor } from './ExpoLocationMonitor'; export type { LocationProvider } from './LocationProvider'; +export { + isBaiduLocationAvailable, + baiduInit, + baiduStartUpdating, + baiduStopUpdating, + subscribeBaiduLocation, +} from './native/BaiduLocationBridge'; diff --git a/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts b/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts new file mode 100644 index 00000000..fb5e8ea9 --- /dev/null +++ b/frontend/src/infrastructure/location/native/BaiduLocationBridge.ts @@ -0,0 +1,83 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; + +export type BaiduLocationSample = { + latitude: number; + longitude: number; + accuracy: number; + observedAt: string; + locType?: number; +}; + +type TimeflowBaiduLocationNative = { + setAgreePrivacy: (agree: boolean) => Promise; + init: (ak: string | null) => Promise; + startUpdating: (intervalMs: number) => Promise; + stopUpdating: () => Promise; + getCurrentPosition: () => Promise; + addListener: (eventName: string) => void; + removeListeners: (count: number) => void; +}; + +const Native = NativeModules.TimeflowBaiduLocation as TimeflowBaiduLocationNative | undefined; + +function getNative(): TimeflowBaiduLocationNative | undefined { + return NativeModules.TimeflowBaiduLocation as TimeflowBaiduLocationNative | undefined; +} + +export function isBaiduLocationAvailable(): boolean { + return Platform.OS === 'android' && getNative() != null; +} + +export async function baiduSetAgreePrivacy(agree: boolean): Promise { + const native = getNative(); + if (native == null) return; + await native.setAgreePrivacy(agree); +} + +export async function baiduInit(ak: string | null = null): Promise { + const native = getNative(); + if (native == null) return false; + await native.setAgreePrivacy(true); + return native.init(ak); +} + +export async function baiduStartUpdating(intervalMs = 5_000): Promise { + const native = getNative(); + if (native == null) return false; + return native.startUpdating(intervalMs); +} + +export async function baiduStopUpdating(): Promise { + const native = getNative(); + if (native == null) return; + await native.stopUpdating(); +} + +export async function baiduGetCurrentPosition(): Promise { + const native = getNative(); + if (native == null) return null; + return native.getCurrentPosition(); +} + +export function subscribeBaiduLocation( + listener: (sample: BaiduLocationSample) => void, +): () => void { + const native = getNative(); + if (native == null || Platform.OS !== 'android') { + return () => {}; + } + const emitter = new NativeEventEmitter(native as never); + const sub = emitter.addListener('TimeflowBaiduLocation', (payload: BaiduLocationSample) => { + if ( + payload == null || + typeof payload.latitude !== 'number' || + typeof payload.longitude !== 'number' + ) { + return; + } + listener(payload); + }); + return () => sub.remove(); +} + +export { Native as TimeflowBaiduLocationNativeModule }; diff --git a/frontend/src/infrastructure/notifications/.gitkeep b/frontend/src/infrastructure/notifications/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/infrastructure/notifications/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts new file mode 100644 index 00000000..5623c600 --- /dev/null +++ b/frontend/src/infrastructure/notifications/ExpoSystemNotification.ts @@ -0,0 +1,72 @@ +import type { + SystemNotificationPort, + SystemNotificationReceipt, + SystemNotificationRequest, +} from '../../features/reminder/application/interfaces'; + +type NotificationsModule = typeof import('expo-notifications'); + +let channelReady: Promise | null = null; + +async function loadNotifications(): Promise { + try { + return await import('expo-notifications'); + } catch { + return null; + } +} + +async function ensureAndroidChannel(Notifications: NotificationsModule): Promise { + if (channelReady != null) { + await channelReady; + return; + } + channelReady = (async () => { + await Notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: Notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + }); + })(); + await channelReady; +} + +/** 基于 expo-notifications 的轻度提醒系统通知。 */ +export class ExpoSystemNotification implements SystemNotificationPort { + async show(request: SystemNotificationRequest): Promise { + const Notifications = await loadNotifications(); + if (Notifications == null) { + return { notification_id: request.notification_id, shown: false }; + } + + await ensureAndroidChannel(Notifications); + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + + await Notifications.scheduleNotificationAsync({ + identifier: request.notification_id, + content: { + title: request.title, + body: request.body, + sound: false, + }, + trigger: null, + }); + + return { notification_id: request.notification_id, shown: true }; + } + + async cancel(notificationId: string): Promise { + const Notifications = await loadNotifications(); + if (Notifications == null) return; + await Notifications.dismissNotificationAsync(notificationId).catch(() => undefined); + await Notifications.cancelScheduledNotificationAsync(notificationId).catch(() => undefined); + } +} diff --git a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts b/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts deleted file mode 100644 index 68b447cf..00000000 --- a/frontend/src/infrastructure/notifications/MockAlarmScheduler.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - AlarmScheduleReceipt, - AlarmScheduleRequest, - AlarmSchedulerPort, -} from '../../features/reminder/application/interfaces'; - -/** 固定闹钟适配器,始终标记为已调度(进程内占位 id)。 */ -export class MockAlarmScheduler implements AlarmSchedulerPort { - async schedule(request: AlarmScheduleRequest): Promise { - return { - alarm_id: `mock-alarm-${request.schedule_id}`, - schedule_id: request.schedule_id, - scheduled: true, - }; - } - - async cancel(_alarmId: string | null): Promise<{ cancelled: boolean }> { - return { cancelled: true }; - } - - async rebuild( - requests: readonly AlarmScheduleRequest[], - ): Promise { - return Promise.all(requests.map((request) => this.schedule(request))); - } -} diff --git a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts b/frontend/src/infrastructure/notifications/MockDeviceCapability.ts deleted file mode 100644 index cda5590c..00000000 --- a/frontend/src/infrastructure/notifications/MockDeviceCapability.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { - DeviceCapabilityPort, - DeviceCapabilityStatus, - DevicePermission, -} from '../../features/reminder/application/interfaces'; - -const MOCK_PERMISSIONS: Readonly> = { - notifications: true, - exact_alarm: true, - overlay: false, - full_screen: true, - battery_optimization: false, - location_foreground: true, - location_background: false, -}; - -const MOCK_STATUS: DeviceCapabilityStatus = { - platform: 'android', - supported: true, - permissions: MOCK_PERMISSIONS, - background_execution: false, -}; - -/** 原生模块接入前使用的固定设备能力状态。 */ -export class MockDeviceCapability implements DeviceCapabilityPort { - async getStatus(): Promise { - return { ...MOCK_STATUS, permissions: { ...MOCK_STATUS.permissions } }; - } - - async requestPermission(permission: DevicePermission): Promise { - // 与 getStatus() 保持一致:返回该权限的固定值,不假装 grant 成功。 - return MOCK_PERMISSIONS[permission]; - } - - async openSettings(_permission: DevicePermission): Promise { - return true; - } -} - -export { MOCK_STATUS as MOCK_DEVICE_CAPABILITY_STATUS }; diff --git a/frontend/src/infrastructure/notifications/MockNotificationChannels.ts b/frontend/src/infrastructure/notifications/MockNotificationChannels.ts deleted file mode 100644 index d42c7a99..00000000 --- a/frontend/src/infrastructure/notifications/MockNotificationChannels.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { - PopupPort, - PopupReceipt, - PopupRequest, - SystemNotificationPort, - SystemNotificationReceipt, - SystemNotificationRequest, - VibrationPort, -} from '../../features/reminder/application/interfaces'; - -export class MockSystemNotification implements SystemNotificationPort { - async show(request: SystemNotificationRequest): Promise { - return { notification_id: request.notification_id, shown: true }; - } - - async cancel(_notificationId: string): Promise { - return Promise.resolve(); - } -} - -export class MockPopup implements PopupPort { - async show(request: PopupRequest): Promise { - return { popup_id: request.popup_id, visible: true }; - } - - async dismiss(_popupId: string): Promise { - return Promise.resolve(); - } -} - -export class MockVibration implements VibrationPort { - async vibrate(): Promise { - return Promise.resolve(); - } - - async stop(): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/infrastructure/notifications/MockReminderDelivery.ts b/frontend/src/infrastructure/notifications/MockReminderDelivery.ts deleted file mode 100644 index f8115703..00000000 --- a/frontend/src/infrastructure/notifications/MockReminderDelivery.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ReminderDeliveryPort } from '../../features/reminder/application/interfaces'; -import type { - ReminderDeliveryReceipt, - ReminderDeliveryRequest, -} from '../../features/reminder/domain'; - -const MOCK_RECEIPT: ReminderDeliveryReceipt = { - delivery_id: 'mock-delivery-001', - schedule_id: 'mock-schedule-time-001', - delivered_at: '2026-08-07T01:00:00.000Z', - channels: ['system_notification'], - used_fallback_audio: false, -}; - -/** 系统通知送达使用的固定通知边界。 */ -export class MockReminderDelivery implements ReminderDeliveryPort { - async deliver(request: ReminderDeliveryRequest): Promise { - return { ...MOCK_RECEIPT, schedule_id: request.schedule_id }; - } - - async dismiss(_scheduleId: string): Promise { - return Promise.resolve(); - } -} - -export { MOCK_RECEIPT as MOCK_REMINDER_DELIVERY_RECEIPT }; diff --git a/frontend/src/infrastructure/notifications/MockReminderRecovery.ts b/frontend/src/infrastructure/notifications/MockReminderRecovery.ts deleted file mode 100644 index 34be1b00..00000000 --- a/frontend/src/infrastructure/notifications/MockReminderRecovery.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { - ReminderRecoveryPort, - ReminderRecoveryReceipt, -} from '../../features/reminder/application/interfaces'; - -/** 固定重启恢复适配器,后续可替换为安卓启动接收器。 */ -export class MockReminderRecovery implements ReminderRecoveryPort { - async registerForRestart(): Promise { - return { registered: true, recovery_id: 'mock-recovery-001' }; - } - - async restoreAfterRestart(): Promise { - return { registered: true, recovery_id: 'mock-recovery-001' }; - } -} diff --git a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts index e2008346..bc8ad7b4 100644 --- a/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts +++ b/frontend/src/infrastructure/notifications/NativeAlarmScheduler.ts @@ -1,4 +1,6 @@ import type { + AlarmNativeDisposition, + AlarmNativeEvent, AlarmScheduleReceipt, AlarmScheduleRequest, AlarmSchedulerPort, @@ -7,10 +9,14 @@ import { isTimeflowAlarmAvailable, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, + nativeCancelAllAlarms, + nativeConsumeAlarmDispositions, nativeScheduleAlarm, + nativeStopAlarmRinging, + subscribeNativeAlarmEvents, } from './native/TimeflowAlarmBridge'; -/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled: false。 */ +/** Android TimeflowAlarm 适配器;无法挂上时返回 scheduled=false。 */ export class NativeAlarmScheduler implements AlarmSchedulerPort { async schedule(request: AlarmScheduleRequest): Promise { if (!isTimeflowAlarmAvailable()) { @@ -22,44 +28,81 @@ export class NativeAlarmScheduler implements AlarmSchedulerPort { return unscheduled(request.schedule_id); } - try { - const ready = await nativeAreAlarmPermissionsGranted(); - if (!ready) { - return unscheduled(request.schedule_id); - } - - const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title); - if (alarmId == null || alarmId.length === 0) { - return unscheduled(request.schedule_id); - } + const ready = await nativeAreAlarmPermissionsGranted(); + if (!ready) { + return unscheduled(request.schedule_id); + } - return { - alarm_id: alarmId, - schedule_id: request.schedule_id, - scheduled: true, - }; - } catch { + const alarmId = await nativeScheduleAlarm(triggerAtMillis, request.title, request.schedule_id); + if (alarmId == null || alarmId.length === 0) { return unscheduled(request.schedule_id); } + + return { + alarm_id: alarmId, + schedule_id: request.schedule_id, + scheduled: true, + }; } async cancel(alarmId: string | null): Promise<{ cancelled: boolean }> { if (alarmId == null || alarmId.length === 0) { return { cancelled: false }; } - const cancelled = await nativeCancelAlarm(alarmId); - return { cancelled }; + await nativeCancelAlarm(alarmId); + return { cancelled: true }; } async rebuild( requests: readonly AlarmScheduleRequest[], ): Promise { + // 冷启动 registrations 为空时也要清掉 SharedPreferences 里的孤儿闹钟。 + await nativeCancelAllAlarms(); const receipts: AlarmScheduleReceipt[] = []; for (const request of requests) { receipts.push(await this.schedule(request)); } return receipts; } + + async stopRinging(): Promise { + await nativeStopAlarmRinging(); + } + + subscribe(listener: (event: AlarmNativeEvent) => void): () => void { + return subscribeNativeAlarmEvents((payload) => { + listener({ + type: payload.type, + schedule_id: payload.scheduleId, + alarm_id: payload.alarmId, + title: payload.title, + at: new Date(payload.atMillis || Date.now()).toISOString(), + }); + }); + } + + async consumeNativeDispositions(): Promise { + const rows = await nativeConsumeAlarmDispositions(); + return rows + .map((row) => { + const state = + row.state === 'confirmed' + ? 'confirmed' + : row.state === 'pending' + ? 'pending' + : row.state === 'snoozed' + ? 'snoozed' + : null; + if (state == null || !row.scheduleId) return null; + return { + schedule_id: row.scheduleId, + alarm_id: row.alarmId ?? '', + state, + updated_at: new Date(row.updatedAtMillis || Date.now()).toISOString(), + } satisfies AlarmNativeDisposition; + }) + .filter((row): row is AlarmNativeDisposition => row != null); + } } function unscheduled(scheduleId: string): AlarmScheduleReceipt { diff --git a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts index b1ce7a66..e7d73d6d 100644 --- a/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts +++ b/frontend/src/infrastructure/notifications/NativeDeviceCapability.ts @@ -1,4 +1,4 @@ -import { Platform } from 'react-native'; +import { AppState, Linking, Platform, type AppStateStatus } from 'react-native'; import type { DeviceCapabilityPort, @@ -22,22 +22,37 @@ const SETTINGS_KIND: Partial< notifications: 'app', }; -/** 基于 TimeflowAlarm 原生模块的设备权限适配器。 */ +/** 基于 TimeflowAlarm + expo-location 的设备权限适配器。 */ export class NativeDeviceCapability implements DeviceCapabilityPort { async getStatus(): Promise { const platform = toPlatform(); + const location = await readLocationPermissions(); + if (!isTimeflowAlarmAvailable()) { - return unsupportedStatus(platform); + return { + platform, + supported: false, + permissions: { + ...emptyPermissions(false), + location_foreground: location.foreground, + location_background: location.background, + }, + background_execution: false, + }; } - let status; - try { - status = await nativeGetAlarmPermissionStatus(); - } catch { - return unsupportedStatus(platform); - } + const status = await nativeGetAlarmPermissionStatus(); if (status == null) { - return unsupportedStatus(platform); + return { + platform, + supported: false, + permissions: { + ...emptyPermissions(false), + location_foreground: location.foreground, + location_background: location.background, + }, + background_execution: false, + }; } return { @@ -49,8 +64,8 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { overlay: status.overlay, full_screen: status.fullScreen, battery_optimization: status.battery, - location_foreground: false, - location_background: false, + location_foreground: location.foreground, + location_background: location.background, }, background_execution: status.battery, }; @@ -58,22 +73,33 @@ export class NativeDeviceCapability implements DeviceCapabilityPort { async requestPermission(permission: DevicePermission): Promise { if (permission === 'notifications') { + return nativeRequestNotificationPermission(); + } + if (permission === 'location_foreground' || permission === 'location_background') { + return requestLocationPermission(permission); + } + return this.openSettings(permission); + } + + async openSettings(permission: DevicePermission): Promise { + if (permission === 'location_foreground' || permission === 'location_background') { try { - return await nativeRequestNotificationPermission(); + await Linking.openSettings(); + return true; } catch { return false; } } - return this.openSettings(permission); + const kind = SETTINGS_KIND[permission] ?? 'app'; + return nativeOpenAlarmPermissionSettings(kind); } - async openSettings(permission: DevicePermission): Promise { - const kind = SETTINGS_KIND[permission] ?? 'app'; - try { - return await nativeOpenAlarmPermissionSettings(kind); - } catch { - return false; - } + onAppActive(listener: () => void): () => void { + const onChange = (state: AppStateStatus) => { + if (state === 'active') listener(); + }; + const subscription = AppState.addEventListener('change', onChange); + return () => subscription.remove(); } } @@ -84,15 +110,6 @@ function toPlatform(): DeviceCapabilityStatus['platform'] { return 'unknown'; } -function unsupportedStatus(platform: DeviceCapabilityStatus['platform']): DeviceCapabilityStatus { - return { - platform, - supported: false, - permissions: emptyPermissions(false), - background_execution: false, - }; -} - function emptyPermissions(value: boolean): Readonly> { return { notifications: value, @@ -104,3 +121,61 @@ function emptyPermissions(value: boolean): Readonly { + try { + const Location = await import('expo-location'); + const foreground = await Location.getForegroundPermissionsAsync(); + const background = await Location.getBackgroundPermissionsAsync(); + return { + foreground: foreground.status === Location.PermissionStatus.GRANTED, + background: background.status === Location.PermissionStatus.GRANTED, + }; + } catch { + return { foreground: false, background: false }; + } +} + +async function requestLocationPermission( + permission: 'location_foreground' | 'location_background', +): Promise { + try { + const Location = await import('expo-location'); + if (permission === 'location_foreground') { + const current = await Location.getForegroundPermissionsAsync(); + if (current.status === Location.PermissionStatus.GRANTED) { + return true; + } + // 系统不再弹授权框时不要空等,交给上层 openSettings。 + if (current.canAskAgain === false) { + return false; + } + const result = await Location.requestForegroundPermissionsAsync(); + return result.status === Location.PermissionStatus.GRANTED; + } + + const foreground = await Location.getForegroundPermissionsAsync(); + if (foreground.status !== Location.PermissionStatus.GRANTED) { + if (foreground.canAskAgain === false) { + return false; + } + const requested = await Location.requestForegroundPermissionsAsync(); + if (requested.status !== Location.PermissionStatus.GRANTED) { + return false; + } + } + + const currentBackground = await Location.getBackgroundPermissionsAsync(); + if (currentBackground.status === Location.PermissionStatus.GRANTED) { + return true; + } + if (currentBackground.canAskAgain === false) { + return false; + } + + const background = await Location.requestBackgroundPermissionsAsync(); + return background.status === Location.PermissionStatus.GRANTED; + } catch { + return false; + } +} diff --git a/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts new file mode 100644 index 00000000..ac779b7a --- /dev/null +++ b/frontend/src/infrastructure/notifications/ReactNativeAlertDialog.ts @@ -0,0 +1,25 @@ +import { Alert } from 'react-native'; + +import type { + AlertDialogPort, + AlertDialogRequest, +} from '../../features/reminder/application/interfaces'; + +/** 系统 Alert 对话框适配器;presentation 通过 AlertDialogPort 使用,不直接依赖 RN。 */ +export class ReactNativeAlertDialog implements AlertDialogPort { + async show(request: AlertDialogRequest): Promise { + Alert.alert( + request.title, + request.message, + request.buttons.map((button) => ({ + text: button.text, + style: button.style, + onPress: button.onPress, + })), + { + cancelable: request.cancelable ?? true, + onDismiss: request.onDismiss, + }, + ); + } +} diff --git a/frontend/src/infrastructure/notifications/ReactNativeVibration.ts b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts new file mode 100644 index 00000000..7bec320b --- /dev/null +++ b/frontend/src/infrastructure/notifications/ReactNativeVibration.ts @@ -0,0 +1,38 @@ +import { Platform, Vibration } from 'react-native'; + +import type { VibrationPort } from '../../features/reminder/application/interfaces'; + +/** 震动 / 间隔,确认或 teardown 前循环。 */ +const REPEAT_PATTERN = [0, 700, 350, 700]; + +/** React Native Vibration 适配器:提醒展示期间持续震动。 */ +export class ReactNativeVibration implements VibrationPort { + private iosTimer: ReturnType | null = null; + + async vibrate(): Promise { + this.clearIosTimer(); + Vibration.cancel(); + + if (Platform.OS === 'android') { + // 第二个参数 true = 按 pattern 循环,直到 cancel。 + Vibration.vibrate(REPEAT_PATTERN, true); + return; + } + + Vibration.vibrate(REPEAT_PATTERN); + this.iosTimer = setInterval(() => { + Vibration.vibrate(REPEAT_PATTERN); + }, 2_000); + } + + async stop(): Promise { + this.clearIosTimer(); + Vibration.cancel(); + } + + private clearIosTimer(): void { + if (this.iosTimer == null) return; + clearInterval(this.iosTimer); + this.iosTimer = null; + } +} diff --git a/frontend/src/infrastructure/notifications/index.ts b/frontend/src/infrastructure/notifications/index.ts index 2ad4c8f1..b34e52ac 100644 --- a/frontend/src/infrastructure/notifications/index.ts +++ b/frontend/src/infrastructure/notifications/index.ts @@ -1,16 +1,18 @@ -export { MockAlarmScheduler } from './MockAlarmScheduler'; export { NativeAlarmScheduler } from './NativeAlarmScheduler'; export { NativeDeviceCapability } from './NativeDeviceCapability'; -export { MockPopup, MockSystemNotification, MockVibration } from './MockNotificationChannels'; -export { MockReminderRecovery } from './MockReminderRecovery'; -export { MockReminderDelivery, MOCK_REMINDER_DELIVERY_RECEIPT } from './MockReminderDelivery'; -export { MockDeviceCapability, MOCK_DEVICE_CAPABILITY_STATUS } from './MockDeviceCapability'; +export { ReactNativeVibration } from './ReactNativeVibration'; +export { ReactNativeAlertDialog } from './ReactNativeAlertDialog'; +export { ExpoSystemNotification } from './ExpoSystemNotification'; export { isTimeflowAlarmAvailable, nativeAreAlarmPermissionsGranted, nativeCancelAlarm, + nativeCancelAllAlarms, + nativeConsumeAlarmDispositions, nativeGetAlarmPermissionStatus, nativeOpenAlarmPermissionSettings, nativeRequestNotificationPermission, nativeScheduleAlarm, + nativeStopAlarmRinging, + subscribeNativeAlarmEvents, } from './native/TimeflowAlarmBridge'; diff --git a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts index d571a321..07550801 100644 --- a/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts +++ b/frontend/src/infrastructure/notifications/native/TimeflowAlarmBridge.ts @@ -1,4 +1,4 @@ -import { NativeModules, Platform } from 'react-native'; +import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; export type NativeAlarmPermissionStatus = { exactAlarm: boolean; @@ -8,66 +8,148 @@ export type NativeAlarmPermissionStatus = { battery: boolean; }; +export type NativeAlarmEventPayload = { + type: 'fired' | 'dismissed' | 'snoozed'; + scheduleId: string; + alarmId: string; + title: string; + atMillis: number; +}; + +export type NativeAlarmDispositionPayload = { + scheduleId: string; + alarmId: string; + state: string; + updatedAtMillis: number; +}; + type TimeflowAlarmNative = { - schedule: (triggerAtMillis: number, title?: string | null) => Promise<{ alarmId: string }>; + schedule: ( + triggerAtMillis: number, + title?: string | null, + scheduleId?: string | null, + ) => Promise<{ alarmId: string; scheduleId?: string }>; cancel: (alarmId: string) => Promise; + cancelAll: () => Promise; + stopRinging: () => Promise; + consumeNativeDispositions: () => Promise; getPermissionStatus: () => Promise; openPermissionSettings: ( kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app', ) => Promise; requestNotificationPermission: () => Promise; + addListener?: (eventName: string) => void; + removeListeners?: (count: number) => void; }; -const NativeAlarm = NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined; +const EVENT_NAME = 'TimeflowAlarmEvent'; + +function getNativeAlarm(): TimeflowAlarmNative | undefined { + return NativeModules.TimeflowAlarm as TimeflowAlarmNative | undefined; +} export function isTimeflowAlarmAvailable(): boolean { - return Platform.OS === 'android' && NativeAlarm != null; + return Platform.OS === 'android' && getNativeAlarm() != null; } export async function nativeScheduleAlarm( triggerAtMillis: number, title: string, + scheduleId?: string, ): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null; + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return null; try { - const result = await NativeAlarm.schedule(triggerAtMillis, title); - const alarmId = result?.alarmId; - if (alarmId == null || alarmId.length === 0) return null; - return alarmId; + const result = await native.schedule(triggerAtMillis, title, scheduleId ?? ''); + return result.alarmId; } catch { return null; } } -export async function nativeCancelAlarm(alarmId: string | null | undefined): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null || !alarmId) return false; +export async function nativeCancelAlarm(alarmId: string | null | undefined): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null || !alarmId) return; try { - return Boolean(await NativeAlarm.cancel(alarmId)); + await native.cancel(alarmId); } catch { - return false; + // 尽力取消,忽略失败。 + } +} + +export async function nativeCancelAllAlarms(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return; + try { + await native.cancelAll(); + } catch { + // 尽力全部取消,忽略失败。 + } +} + +export async function nativeStopAlarmRinging(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return; + try { + await native.stopRinging(); + } catch { + // 尽力停铃,忽略失败。 + } +} + +export async function nativeConsumeAlarmDispositions(): Promise { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return []; + try { + return await native.consumeNativeDispositions(); + } catch { + return []; } } export async function nativeGetAlarmPermissionStatus(): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return null; - return NativeAlarm.getPermissionStatus(); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return null; + return native.getPermissionStatus(); } export async function nativeOpenAlarmPermissionSettings( kind: 'exactAlarm' | 'overlay' | 'fullScreen' | 'battery' | 'app', ): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false; - return NativeAlarm.openPermissionSettings(kind); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return false; + return native.openPermissionSettings(kind); } export async function nativeRequestNotificationPermission(): Promise { - if (!isTimeflowAlarmAvailable() || NativeAlarm == null) return false; - return NativeAlarm.requestNotificationPermission(); + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) return false; + try { + return await native.requestNotificationPermission(); + } catch { + return false; + } } export async function nativeAreAlarmPermissionsGranted(): Promise { const status = await nativeGetAlarmPermissionStatus(); if (status == null) return false; - // 挂闹钟的最低要求:精确闹钟 + 通知;悬浮窗/全屏/电池影响展示,不阻塞调度。 return status.exactAlarm && status.notifications; } + +export function subscribeNativeAlarmEvents( + listener: (event: NativeAlarmEventPayload) => void, +): () => void { + const native = getNativeAlarm(); + if (!isTimeflowAlarmAvailable() || native == null) { + return () => undefined; + } + const emitter = new NativeEventEmitter(native as never); + const subscription = emitter.addListener(EVENT_NAME, (payload: NativeAlarmEventPayload) => { + if (payload?.type !== 'fired' && payload?.type !== 'dismissed' && payload?.type !== 'snoozed') { + return; + } + listener(payload); + }); + return () => subscription.remove(); +} diff --git a/frontend/src/infrastructure/time/IntervalTimeListener.ts b/frontend/src/infrastructure/time/IntervalTimeListener.ts new file mode 100644 index 00000000..5099d98e --- /dev/null +++ b/frontend/src/infrastructure/time/IntervalTimeListener.ts @@ -0,0 +1,38 @@ +import type { + LocalTimeTick, + TimeListenerHandle, + TimeListenerOptions, + TimeListenerPort, +} from '../../features/reminder/application/interfaces'; + +const DEFAULT_INTERVAL_MS = 30_000; + +/** 进程内周期时间观测,驱动 handleTime。 */ +export class IntervalTimeListener implements TimeListenerPort { + private readonly timers = new Map>(); + private sequence = 0; + + constructor(private readonly intervalMs: number = DEFAULT_INTERVAL_MS) {} + + async start( + listener: (tick: LocalTimeTick) => void, + _options?: TimeListenerOptions, + ): Promise { + const listenerId = `interval-time-listener-${++this.sequence}`; + const emit = () => { + listener({ observed_at: new Date().toISOString() }); + }; + // 不在 start 同步打首 tick,交给调用方完成 rebuild 后再自然周期触发。 + const timer = setInterval(emit, this.intervalMs); + this.timers.set(listenerId, timer); + return { listener_id: listenerId }; + } + + async stop(listenerId: string): Promise { + const timer = this.timers.get(listenerId); + if (timer != null) { + clearInterval(timer); + this.timers.delete(listenerId); + } + } +} diff --git a/frontend/src/infrastructure/time/index.ts b/frontend/src/infrastructure/time/index.ts new file mode 100644 index 00000000..299256c7 --- /dev/null +++ b/frontend/src/infrastructure/time/index.ts @@ -0,0 +1 @@ +export { IntervalTimeListener } from './IntervalTimeListener'; diff --git a/frontend/src/shared/time/.gitkeep b/frontend/src/shared/time/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/shared/time/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/shared/time/MockClock.ts b/frontend/src/shared/time/MockClock.ts deleted file mode 100644 index edc7ff1c..00000000 --- a/frontend/src/shared/time/MockClock.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** 用于确定性测试和模拟组合的最小时钟端口。 */ -export interface Clock { - nowIso(): string; -} - -export class MockClock implements Clock { - constructor(private readonly value = '2026-08-07T01:00:00.000Z') {} - - nowIso(): string { - return this.value; - } -} diff --git a/frontend/src/shared/time/MockTimeListener.ts b/frontend/src/shared/time/MockTimeListener.ts deleted file mode 100644 index e77e36ec..00000000 --- a/frontend/src/shared/time/MockTimeListener.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - LocalTimeTick, - TimeListenerHandle, - TimeListenerOptions, - TimeListenerPort, -} from '../../features/reminder/application/interfaces'; - -/** 固定时间监听边界,替换前不会发出平台事件。 */ -export class MockTimeListener implements TimeListenerPort { - async start( - _listener: (tick: LocalTimeTick) => void, - _options?: TimeListenerOptions, - ): Promise { - return { listener_id: 'mock-time-listener-001' }; - } - - async stop(_listenerId: string): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/shared/time/format.ts b/frontend/src/shared/time/format.ts new file mode 100644 index 00000000..71609c15 --- /dev/null +++ b/frontend/src/shared/time/format.ts @@ -0,0 +1,19 @@ +/** + * 无业务语义的时间工具(附录 B.5)。 + * 不承载日程/提醒触发规则。 + */ +export function toIsoUtc(date: Date = new Date()): string { + return date.toISOString(); +} + +export function formatLocalDateTime( + iso: string, + timeZone = 'Asia/Shanghai', + locale = 'zh-CN', +): string { + try { + return new Date(iso).toLocaleString(locale, { hour12: false, timeZone }); + } catch { + return iso; + } +} diff --git a/frontend/src/shared/time/index.ts b/frontend/src/shared/time/index.ts index 1924ce7a..00679748 100644 --- a/frontend/src/shared/time/index.ts +++ b/frontend/src/shared/time/index.ts @@ -1,3 +1 @@ -export { MockClock } from './MockClock'; -export type { Clock } from './MockClock'; -export { MockTimeListener } from './MockTimeListener'; +export { formatLocalDateTime, toIsoUtc } from './format'; diff --git a/frontend/src/types/.gitkeep b/frontend/src/types/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/frontend/src/types/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/types/expo-audio.d.ts b/frontend/src/types/expo-audio.d.ts new file mode 100644 index 00000000..6638cb05 --- /dev/null +++ b/frontend/src/types/expo-audio.d.ts @@ -0,0 +1,16 @@ +declare module 'expo-audio' { + export function createAudioPlayer(source?: string | number | null): { + pause: () => void; + replace: (source: string | number) => void; + play: () => void; + volume: number; + loop: boolean; + }; + + export function setAudioModeAsync(mode: Record): Promise; +} + +declare module '*.mp3' { + const asset: number; + export default asset; +} diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts new file mode 100644 index 00000000..5988a996 --- /dev/null +++ b/frontend/tests/integration/localScheduleWriter.test.ts @@ -0,0 +1,81 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocalScheduleWriter } from '../../src/features/assistant/data/local/LocalScheduleWriter'; +import type { AppliedCommand } from '../../src/features/assistant/domain/ConversationTurn'; +import { ScheduleLocalRepository } from '../../src/features/schedule/data'; +import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +function appliedCommand(overrides: Partial = {}): AppliedCommand { + return { + operation: 'create_schedule', + status: 'applied', + schedule: { + id: 'schedule-a', + schedule_type: 'time', + schedule_kind: 'once', + title: 'Team sync', + is_all_day: false, + timezone: 'Asia/Shanghai', + start_time: '2026-08-12T07:00:00Z', + revision: 1, + }, + ...overrides, + }; +} + +describe('LocalScheduleWriter refresh wiring', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + }); + + afterEach(() => { + database.close(); + }); + + it('refreshes the attached schedule reader after a successful write', async () => { + const reader = new SqliteLocalScheduleReader(); + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + const writer = new LocalScheduleWriter(repository, reader); + await writer.applyCommandResult('account-a', appliedCommand()); + + expect(listener).toHaveBeenCalledTimes(1); + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ title: 'Team sync' }); + }); + + it('does not refresh when the command was not applied', async () => { + const reader = new SqliteLocalScheduleReader(); + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + const writer = new LocalScheduleWriter(repository, reader); + await writer.applyCommandResult('account-a', appliedCommand({ status: 'rejected' })); + + expect(listener).not.toHaveBeenCalled(); + expect(await reader.getReminderSchedule('schedule-a')).toBeNull(); + }); + + it('works without a schedule reader wired in', async () => { + const writer = new LocalScheduleWriter(repository); + await expect(writer.applyCommandResult('account-a', appliedCommand())).resolves.toBeUndefined(); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.title).toBe('Team sync'); + }); +}); diff --git a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts new file mode 100644 index 00000000..79413277 --- /dev/null +++ b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts @@ -0,0 +1,132 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; +import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +function cloudSchedule(overrides: Partial = {}): CloudScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'once', + title: 'Original title', + is_all_day: 0, + start_time: '2026-08-12T07:00:00Z', + end_time: null, + timezone: 'Asia/Shanghai', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + reminder_type: 'before_start', + reminder_trigger_at: null, + reminder_offset_minutes: 15, + reminder_strength: 'medium', + reminder_disposition_state: null, + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-11T07:00:00Z', + ...overrides, + }; +} + +describe('SqliteLocalScheduleReader', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + let reader: SqliteLocalScheduleReader; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + reader = new SqliteLocalScheduleReader(); + }); + + afterEach(() => { + database.close(); + }); + + it('returns nothing before attach()', async () => { + expect(await reader.listReminderSchedules()).toEqual([]); + expect(await reader.getReminderSchedule('schedule-a')).toBeNull(); + }); + + it('maps a stored row onto the reminder domain shape once attached', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-a'); + + const schedules = await reader.listReminderSchedules(); + expect(schedules).toHaveLength(1); + expect(schedules[0]).toMatchObject({ + id: 'schedule-a', + account_id: 'account-a', + title: 'Original title', + geofence_radius_meters: 200, + reminder: { + reminder_type: 'before_start', + reminder_offset_minutes: 15, + reminder_strength: 'medium', + }, + runtime: { + geofence_armed: false, + recorded_location: null, + sync_status: 'synced', + }, + status: 'active', + }); + + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ id: 'schedule-a' }); + expect(await reader.getReminderSchedule('missing')).toBeNull(); + }); + + it('scopes reads to the attached account', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-b'); + + expect(await reader.listReminderSchedules()).toEqual([]); + }); + + it('stops returning data after detach()', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-a'); + expect(await reader.listReminderSchedules()).toHaveLength(1); + + reader.detach(); + + expect(await reader.listReminderSchedules()).toEqual([]); + }); + + it('notifies subscribers with a fresh read on refresh()', async () => { + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + await repository.applyCloudSchedule(cloudSchedule()); + await reader.refresh(); + + expect(listener).toHaveBeenCalledTimes(1); + const [notified] = listener.mock.calls[0] as [readonly { id: string }[]]; + expect(notified).toHaveLength(1); + expect(notified[0].id).toBe('schedule-a'); + }); + + it('stops notifying an unsubscribed listener', async () => { + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + const unsubscribe = reader.subscribe(listener); + unsubscribe(); + + await repository.applyCloudSchedule(cloudSchedule()); + await reader.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/app/AppRoot.test.tsx b/frontend/tests/unit/app/AppRoot.test.tsx index 2368b47c..ce522c1a 100644 --- a/frontend/tests/unit/app/AppRoot.test.tsx +++ b/frontend/tests/unit/app/AppRoot.test.tsx @@ -9,7 +9,12 @@ import { openTimeflowDatabase } from '../../../src/infrastructure/database'; jest.mock('../../../src/infrastructure/database', () => ({ openTimeflowDatabase: jest.fn<() => Promise>().mockResolvedValue({}), })); -jest.mock('../../../src/features/schedule/data', () => ({ ScheduleLocalRepository: jest.fn() })); +jest.mock('../../../src/features/schedule/data', () => ({ + ScheduleLocalRepository: jest.fn().mockImplementation(() => ({ + getSchedule: jest.fn<() => Promise>().mockResolvedValue(null), + listSchedules: jest.fn<() => Promise>().mockResolvedValue([]), + })), +})); jest.mock('../../../src/features/schedule/application', () => ({ SqliteScheduleClientService: jest.fn(), })); diff --git a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts b/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts deleted file mode 100644 index 831b5244..00000000 --- a/frontend/tests/unit/features/reminder/useReminderPermissionsOnLaunch.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { act, renderHook } from '@testing-library/react-native'; -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { Alert, AppState, Platform, type AlertButton } from 'react-native'; - -import type { - DeviceCapabilityPort, - DeviceCapabilityStatus, - DevicePermission, -} from '../../../../src/features/reminder/application/interfaces'; -import { useReminderPermissionsOnLaunch } from '../../../../src/features/reminder/presentation/useReminderPermissionsOnLaunch'; - -const ALERT_OPTIONS = expect.objectContaining({ - cancelable: true, - onDismiss: expect.any(Function), -}); - -function deniedPermissions(): Record { - return { - notifications: false, - exact_alarm: false, - overlay: false, - full_screen: false, - battery_optimization: false, - location_foreground: false, - location_background: false, - }; -} - -function grantedPermissions(): Record { - return { - notifications: true, - exact_alarm: true, - overlay: true, - full_screen: true, - battery_optimization: true, - location_foreground: true, - location_background: true, - }; -} - -type FakeDevice = DeviceCapabilityPort & { status: DeviceCapabilityStatus }; - -function createDevice( - status: Partial & Pick = { - platform: 'android', - }, -): FakeDevice { - const device: FakeDevice = { - status: { - platform: status.platform, - supported: status.supported ?? status.platform === 'android', - permissions: { ...deniedPermissions(), ...status.permissions }, - background_execution: status.background_execution ?? true, - }, - getStatus: jest.fn(async () => device.status), - requestPermission: jest.fn(async (permission: DevicePermission) => { - device.status = { - ...device.status, - permissions: { ...device.status.permissions, [permission]: true }, - }; - return true; - }), - openSettings: jest.fn(async () => true), - }; - return device; -} - -let alertButtons: readonly AlertButton[] = []; -let dismissAlertCallback: (() => void) | undefined; -const appStateListeners: ((state: string) => void)[] = []; - -async function flush(ms = 0): Promise { - await act(async () => { - if (ms > 0) { - jest.advanceTimersByTime(ms); - } - for (let index = 0; index < 8; index += 1) { - await Promise.resolve(); - } - }); -} - -async function press(label: string): Promise { - await act(async () => { - alertButtons.find((button) => button.text === label)?.onPress?.(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -async function dismissAlert(): Promise { - await act(async () => { - dismissAlertCallback?.(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe('useReminderPermissionsOnLaunch', () => { - beforeEach(() => { - alertButtons = []; - appStateListeners.length = 0; - Platform.OS = 'android'; - dismissAlertCallback = undefined; - jest.spyOn(Alert, 'alert').mockImplementation((_title, _message, buttons, options) => { - alertButtons = buttons ?? []; - dismissAlertCallback = options?.onDismiss; - }); - jest.spyOn(AppState, 'addEventListener').mockImplementation((_event, listener) => { - appStateListeners.push(listener as (state: string) => void); - return { remove: jest.fn() } as ReturnType; - }); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - }); - - it('does nothing off Android or without a device', async () => { - jest.useFakeTimers(); - const androidDevice = createDevice(); - Platform.OS = 'ios'; - renderHook(() => useReminderPermissionsOnLaunch(androidDevice)); - Platform.OS = 'android'; - renderHook(() => useReminderPermissionsOnLaunch(null)); - await flush(1_000); - expect(androidDevice.getStatus).not.toHaveBeenCalled(); - }); - - it('requests notifications directly without an explanation alert', async () => { - jest.useFakeTimers(); - const device = createDevice({ platform: 'android', supported: true }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(Alert.alert).not.toHaveBeenCalled(); - expect(device.requestPermission).toHaveBeenCalledWith('notifications'); - }); - - it('asks before opening settings for exact alarm, then resumes on app active', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(Alert.alert).toHaveBeenCalledWith( - '需要精确闹钟权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - await press('去授权'); - expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); - expect(device.requestPermission).not.toHaveBeenCalled(); - - device.status = { - ...device.status, - permissions: { ...device.status.permissions, exact_alarm: true }, - }; - await act(async () => { - appStateListeners.forEach((listener) => listener('active')); - await Promise.resolve(); - }); - await flush(300); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips a permission when the user declines the explanation alert', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('暂不'); - await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips and continues when the explanation alert is dismissed', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await dismissAlert(); - await flush(250); - expect(device.openSettings).not.toHaveBeenCalled(); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips notifications and continues when requestPermission rejects', async () => { - jest.useFakeTimers(); - const device = createDevice({ platform: 'android', supported: true }); - device.requestPermission = jest.fn(async () => { - throw new Error('native prompt failed'); - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.requestPermission).toHaveBeenCalledWith('notifications'); - await flush(350); - expect(Alert.alert).toHaveBeenCalledWith( - '需要精确闹钟权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips and continues when openSettings returns false', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - device.openSettings = jest.fn(async () => false); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('去授权'); - expect(device.openSettings).toHaveBeenCalledWith('exact_alarm'); - await flush(200); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('skips and continues when openSettings throws', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: { ...deniedPermissions(), notifications: true }, - }); - device.openSettings = jest.fn(async () => { - throw new Error('settings unavailable'); - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - await press('去授权'); - await flush(200); - expect(Alert.alert).toHaveBeenCalledWith( - '需要悬浮窗权限', - expect.any(String), - expect.any(Array), - ALERT_OPTIONS, - ); - }); - - it('does not run delayed prompts after unmount', async () => { - jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); - unmount(); - await flush(1_000); - expect(device.getStatus).not.toHaveBeenCalled(); - }); - - it('cancels pending follow-up prompts on unmount', async () => { - jest.useFakeTimers(); - const device = createDevice(); - const { unmount } = renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.getStatus).toHaveBeenCalledTimes(1); - unmount(); - await flush(1_000); - expect(device.getStatus).toHaveBeenCalledTimes(1); - expect(Alert.alert).not.toHaveBeenCalled(); - }); - - it('does not prompt when every permission is already granted', async () => { - jest.useFakeTimers(); - const device = createDevice({ - platform: 'android', - supported: true, - permissions: grantedPermissions(), - }); - renderHook(() => useReminderPermissionsOnLaunch(device)); - await flush(600); - expect(device.requestPermission).not.toHaveBeenCalled(); - expect(Alert.alert).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts deleted file mode 100644 index b9b2ff62..00000000 --- a/frontend/tests/unit/infrastructure/notifications/nativeAlarmScheduler.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { NativeModules, Platform } from 'react-native'; - -import type { AlarmScheduleRequest } from '../../../../src/features/reminder/application/interfaces'; -import { NativeAlarmScheduler } from '../../../../src/infrastructure/notifications/NativeAlarmScheduler'; -import { - isTimeflowAlarmAvailable, - nativeAreAlarmPermissionsGranted, - nativeCancelAlarm, - nativeScheduleAlarm, -} from '../../../../src/infrastructure/notifications/native/TimeflowAlarmBridge'; - -jest.mock('react-native', () => { - const RN = jest.requireActual('react-native') as typeof import('react-native'); - RN.NativeModules.TimeflowAlarm = { - schedule: jest.fn(), - cancel: jest.fn(), - getPermissionStatus: jest.fn(), - openPermissionSettings: jest.fn(), - requestNotificationPermission: jest.fn(), - }; - return RN; -}); - -type NativeAlarmMock = { - schedule: jest.MockedFunction< - (triggerAtMillis: number, title?: string | null) => Promise<{ alarmId: string }> - >; - cancel: jest.MockedFunction<(alarmId: string) => Promise>; - getPermissionStatus: jest.MockedFunction< - () => Promise<{ - exactAlarm: boolean; - overlay: boolean; - fullScreen: boolean; - notifications: boolean; - battery: boolean; - }> - >; -}; - -const NOW = '2026-08-13T08:00:00.000Z'; -const FUTURE = '2026-08-13T09:00:00.000Z'; -const PAST = '2026-08-13T07:00:00.000Z'; - -const native = NativeModules.TimeflowAlarm as NativeAlarmMock; - -function request(overrides: Partial = {}): AlarmScheduleRequest { - return { - schedule_id: 'schedule-1', - trigger_at: FUTURE, - title: '晨会', - exact: true, - ...overrides, - }; -} - -function grantPermissions( - overrides: Partial<{ - exactAlarm: boolean; - overlay: boolean; - fullScreen: boolean; - notifications: boolean; - battery: boolean; - }> = {}, -) { - native.getPermissionStatus.mockResolvedValue({ - exactAlarm: true, - overlay: false, - fullScreen: false, - notifications: true, - battery: false, - ...overrides, - }); -} - -describe('TimeflowAlarmBridge and NativeAlarmScheduler', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(NOW)); - Platform.OS = 'android'; - native.schedule.mockReset(); - native.cancel.mockReset(); - native.getPermissionStatus.mockReset(); - grantPermissions(); - native.schedule.mockResolvedValue({ alarmId: 'alarm-1' }); - native.cancel.mockResolvedValue(true); - }); - - afterEach(() => { - jest.useRealTimers(); - Platform.OS = 'android'; - }); - - it('reports the bridge unavailable off Android', () => { - Platform.OS = 'ios'; - expect(isTimeflowAlarmAvailable()).toBe(false); - }); - - it('returns unscheduled when the native module is unavailable', async () => { - Platform.OS = 'ios'; - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.getPermissionStatus).not.toHaveBeenCalled(); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled for an invalid trigger time', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request({ trigger_at: 'not-a-date' }))).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled for a trigger at or before now', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request({ trigger_at: PAST }))).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - await expect(scheduler.schedule(request({ trigger_at: NOW }))).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled when exact-alarm permission is missing', async () => { - grantPermissions({ exactAlarm: false }); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('returns unscheduled when notification permission is missing', async () => { - grantPermissions({ notifications: false }); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('does not require overlay, full-screen, or battery permission to schedule', async () => { - grantPermissions({ overlay: false, fullScreen: false, battery: false }); - await expect(nativeAreAlarmPermissionsGranted()).resolves.toBe(true); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: 'alarm-1', - schedule_id: 'schedule-1', - scheduled: true, - }); - }); - - it('schedules a future alarm and returns scheduled: true', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: 'alarm-1', - schedule_id: 'schedule-1', - scheduled: true, - }); - expect(native.schedule).toHaveBeenCalledWith(Date.parse(FUTURE), '晨会'); - }); - - it('maps a native schedule rejection to unscheduled', async () => { - native.schedule.mockRejectedValue(new Error('exact alarm denied')); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - await expect(nativeScheduleAlarm(Date.parse(FUTURE), '晨会')).resolves.toBeNull(); - }); - - it('maps an empty native alarm id to unscheduled', async () => { - native.schedule.mockResolvedValue({ alarmId: '' }); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - }); - - it('maps a permission-status rejection to unscheduled', async () => { - native.getPermissionStatus.mockRejectedValue(new Error('status unavailable')); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.schedule(request())).resolves.toEqual({ - alarm_id: '', - schedule_id: 'schedule-1', - scheduled: false, - }); - expect(native.schedule).not.toHaveBeenCalled(); - }); - - it('forwards cancel true and false from the native module', async () => { - const scheduler = new NativeAlarmScheduler(); - native.cancel.mockResolvedValueOnce(true); - await expect(scheduler.cancel('alarm-1')).resolves.toEqual({ cancelled: true }); - native.cancel.mockResolvedValueOnce(false); - await expect(scheduler.cancel('alarm-1')).resolves.toEqual({ cancelled: false }); - expect(native.cancel).toHaveBeenCalledTimes(2); - }); - - it('returns cancelled: false when cancel is called with an empty id', async () => { - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.cancel(null)).resolves.toEqual({ cancelled: false }); - await expect(scheduler.cancel('')).resolves.toEqual({ cancelled: false }); - expect(native.cancel).not.toHaveBeenCalled(); - }); - - it('maps a native cancel rejection to cancelled: false', async () => { - native.cancel.mockRejectedValue(new Error('cancel failed')); - const scheduler = new NativeAlarmScheduler(); - await expect(scheduler.cancel('alarm-1')).resolves.toEqual({ cancelled: false }); - await expect(nativeCancelAlarm('alarm-1')).resolves.toBe(false); - }); - - it('rebuilds mixed requests in order', async () => { - native.schedule.mockResolvedValueOnce({ alarmId: 'alarm-ok' }).mockResolvedValueOnce({ - alarmId: 'alarm-later', - }); - const scheduler = new NativeAlarmScheduler(); - const receipts = await scheduler.rebuild([ - request({ schedule_id: 'ok' }), - request({ schedule_id: 'expired', trigger_at: PAST }), - request({ schedule_id: 'later', trigger_at: '2026-08-13T10:00:00.000Z', title: '午会' }), - ]); - expect(receipts).toEqual([ - { alarm_id: 'alarm-ok', schedule_id: 'ok', scheduled: true }, - { alarm_id: '', schedule_id: 'expired', scheduled: false }, - { alarm_id: 'alarm-later', schedule_id: 'later', scheduled: true }, - ]); - expect(native.schedule).toHaveBeenCalledTimes(2); - expect(native.schedule).toHaveBeenNthCalledWith(1, Date.parse(FUTURE), '晨会'); - expect(native.schedule).toHaveBeenNthCalledWith( - 2, - Date.parse('2026-08-13T10:00:00.000Z'), - '午会', - ); - }); -}); diff --git a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts b/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts deleted file mode 100644 index a7860009..00000000 --- a/frontend/tests/unit/infrastructure/notifications/nativeDeviceCapability.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { beforeEach, describe, expect, it, jest } from '@jest/globals'; -import { Platform } from 'react-native'; - -import { NativeDeviceCapability } from '../../../../src/infrastructure/notifications/NativeDeviceCapability'; -import { - isTimeflowAlarmAvailable, - nativeGetAlarmPermissionStatus, - nativeOpenAlarmPermissionSettings, - nativeRequestNotificationPermission, -} from '../../../../src/infrastructure/notifications/native/TimeflowAlarmBridge'; - -jest.mock('../../../../src/infrastructure/notifications/native/TimeflowAlarmBridge', () => ({ - isTimeflowAlarmAvailable: jest.fn(), - nativeGetAlarmPermissionStatus: jest.fn(), - nativeOpenAlarmPermissionSettings: jest.fn(), - nativeRequestNotificationPermission: jest.fn(), -})); - -const available = isTimeflowAlarmAvailable as jest.MockedFunction; -const getStatus = nativeGetAlarmPermissionStatus as jest.MockedFunction< - typeof nativeGetAlarmPermissionStatus ->; -const openSettings = nativeOpenAlarmPermissionSettings as jest.MockedFunction< - typeof nativeOpenAlarmPermissionSettings ->; -const requestNotifications = nativeRequestNotificationPermission as jest.MockedFunction< - typeof nativeRequestNotificationPermission ->; - -describe('NativeDeviceCapability', () => { - beforeEach(() => { - Platform.OS = 'android'; - available.mockReset().mockReturnValue(true); - getStatus.mockReset(); - openSettings.mockReset().mockResolvedValue(true); - requestNotifications.mockReset().mockResolvedValue(true); - }); - - it('reports unsupported when the native module is unavailable', async () => { - available.mockReturnValue(false); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toMatchObject({ - platform: 'android', - supported: false, - background_execution: false, - permissions: { - notifications: false, - exact_alarm: false, - overlay: false, - full_screen: false, - battery_optimization: false, - location_foreground: false, - location_background: false, - }, - }); - expect(getStatus).not.toHaveBeenCalled(); - }); - - it('reports unsupported when permission status is missing', async () => { - getStatus.mockResolvedValue(null); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toMatchObject({ - supported: false, - background_execution: false, - }); - }); - - it('reports unsupported when the native status read rejects', async () => { - getStatus.mockRejectedValue(new Error('bridge unavailable')); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toMatchObject({ - platform: 'android', - supported: false, - background_execution: false, - permissions: { - notifications: false, - exact_alarm: false, - overlay: false, - full_screen: false, - battery_optimization: false, - location_foreground: false, - location_background: false, - }, - }); - }); - - it('maps native permission flags onto the device port', async () => { - getStatus.mockResolvedValue({ - exactAlarm: true, - overlay: false, - fullScreen: true, - notifications: true, - battery: true, - }); - const device = new NativeDeviceCapability(); - await expect(device.getStatus()).resolves.toEqual({ - platform: 'android', - supported: true, - background_execution: true, - permissions: { - notifications: true, - exact_alarm: true, - overlay: false, - full_screen: true, - battery_optimization: true, - location_foreground: false, - location_background: false, - }, - }); - }); - - it('requests notification permission through the native prompt', async () => { - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('notifications')).resolves.toBe(true); - expect(requestNotifications).toHaveBeenCalledTimes(1); - expect(openSettings).not.toHaveBeenCalled(); - }); - - it('returns false when the native notification permission request rejects', async () => { - requestNotifications.mockRejectedValue(new Error('prompt failed')); - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('notifications')).resolves.toBe(false); - }); - - it('returns false when opening settings rejects', async () => { - openSettings.mockRejectedValue(new Error('intent failed')); - const device = new NativeDeviceCapability(); - await expect(device.openSettings('overlay')).resolves.toBe(false); - await expect(device.requestPermission('exact_alarm')).resolves.toBe(false); - }); - - it('opens the matching settings page for non-notification permissions', async () => { - const device = new NativeDeviceCapability(); - await expect(device.requestPermission('exact_alarm')).resolves.toBe(true); - await expect(device.openSettings('overlay')).resolves.toBe(true); - await expect(device.openSettings('full_screen')).resolves.toBe(true); - await expect(device.openSettings('battery_optimization')).resolves.toBe(true); - await expect(device.openSettings('location_foreground')).resolves.toBe(true); - expect(openSettings.mock.calls.map((call) => call[0])).toEqual([ - 'exactAlarm', - 'overlay', - 'fullScreen', - 'battery', - 'app', - ]); - }); -}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index db2da661..0d62f629 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -2,5 +2,6 @@ "extends": "./node_modules/expo/tsconfig.base.json", "compilerOptions": { "strict": true - } + }, + "exclude": ["scripts"] } From f7d05cd75b7f000ccf7143e4db90f740eae722c4 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 10:54:47 +0800 Subject: [PATCH 2/9] ci(frontend): give the Baidu location plugin a placeholder API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expo export 只是导出/校验配置,不产出真机可用的原生包,但 withTimeflowBaiduLocation 插件在 apiKey 缺失时会直接 throw,把 Export build 这一步卡死。CI 不需要真的百度 Key,塞一个占位值就够通过配置校验。 --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d836b52e..678d124b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,4 +139,8 @@ jobs: run: npm run check - name: Export build + env: + # 只是导出/校验配置,不产出真机可用的原生包;占位值够让百度定位插件的 + # apiKey 校验通过,不需要在 CI 里存真的百度 Key。 + TIMEFLOW_BAIDU_LOCATION_API_KEY: ci-placeholder run: npx expo export --platform android --output-dir dist From d604ea07c2b583af58206a53a28e53d7bfa4ceeb Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 12:09:50 +0800 Subject: [PATCH 3/9] fix(reminder): advance recurring reminders to their next occurrence Recurring schedules only ever fired once (or not at all for freshly synced ones): resolveTimeTriggerAt() requires a non-null runtime occurrence cursor for recurring schedules and returns null otherwise, but nothing ever computed or advanced that cursor -- confirmInternal() explicitly clears it back to null on every confirm (that's its signal for "this occurrence is done"), and a freshly-synced recurring row never had one to begin with. Fixing this in SqliteLocalScheduleReader would have been dead code: LocalReminderApplication.withStoredRuntime() unconditionally overrides whatever runtime the reader returns with SqliteReminderStateStore.read()'s result, so the real fix has to live there. When a recurring schedule's cursor is null, read() now computes the next RRULE occurrence at/after now from start_time + recurrence_rule, and resets reminder_disposition_state (otherwise canDeliver() would keep treating the new occurrence as already confirmed forever). A series that's run out of occurrences (COUNT/UNTIL exhausted) is left alone rather than looping. --- .../data/local/SqliteReminderStateStore.ts | 64 ++++++- .../sqliteReminderStateStore.test.ts | 170 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/integration/sqliteReminderStateStore.test.ts diff --git a/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts index 232df5da..5f678c9a 100644 --- a/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts +++ b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts @@ -1,4 +1,19 @@ -import type { LocalReminderRuntimeUpdate, ScheduleLocalRepository } from '../../../schedule/data'; +import type { + LocalReminderRuntimeUpdate, + LocalScheduleRow, + ScheduleLocalRepository, +} from '../../../schedule/data'; +import { + floatingDateToLocalParts, + instantToZonedParts, + isValidIanaTimezone, + localPartsToFloatingDate, + zonedPartsToInstant, +} from '../../../schedule/domain/scheduleDateTime'; +import { + normalizeUtcUntilForFloatingRrule, + parseScheduleRrule, +} from '../../../schedule/domain/scheduleRecurrence'; import type { ReminderStateStore } from '../../application/interfaces'; import type { ReminderDisposition, ReminderRuntimeState } from '../../domain'; @@ -27,6 +42,31 @@ export class SqliteReminderStateStore implements ReminderStateStore { if (this.target === null) return null; const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId); if (row === null) return null; + + // 重复日程的 occurrence 光标为空:要么是云端刚同步下来的新记录(从没落过库), + // 要么是上一次 occurrence 被 confirmInternal 消费后显式清空的(它就是靠置空 + // next_trigger_at 触发"这条该往前挪一格了")。resolveTimeTriggerAt() 对重复 + // 日程光标为空时明确返回 null,不会回退到系列 start_time,所以这里必须补上 + // 下一次发生时间,不然这条提醒永远不会再触发第二次。同时把上一轮的 + // disposition 状态一起重置,否则 canDeliver() 会因为它还是 'confirmed' 继续 + // 拦住这条全新的 occurrence。 + if (row.schedule_kind === 'recurring' && row.next_trigger_at === null) { + const nextOccurrence = nextRecurringOccurrenceAtOrAfter(row, new Date()); + if (nextOccurrence !== null) { + return { + reminder_disposition_state: null, + next_trigger_at: nextOccurrence, + snoozed_until: null, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: null, + sync_status: row.sync_status, + recorded_location: null, + }; + } + // 系列已经结束(RRULE 的 UNTIL/COUNT 用完了)或规则本身有问题:没有下一次 + // 发生时间可算,透传原始(空)状态,让上层照常按"这条排不上"处理。 + } + return { reminder_disposition_state: row.reminder_disposition_state, next_trigger_at: row.next_trigger_at, @@ -62,6 +102,28 @@ export class SqliteReminderStateStore implements ReminderStateStore { } } +/** 重复日程从 now 起(含 now 本身)的下一次发生时间;规则用完/无效时返回 null。 */ +function nextRecurringOccurrenceAtOrAfter(row: LocalScheduleRow, now: Date): string | null { + if (row.start_time === null || row.recurrence_rule === null) return null; + if (!isValidIanaTimezone(row.timezone)) return null; + + try { + const floatingStart = localPartsToFloatingDate( + instantToZonedParts(new Date(row.start_time), row.timezone), + ); + const rule = parseScheduleRrule( + normalizeUtcUntilForFloatingRrule(row.recurrence_rule, row.timezone), + floatingStart, + ); + const floatingNow = localPartsToFloatingDate(instantToZonedParts(now, row.timezone)); + const nextFloating = rule.after(floatingNow, true); + if (nextFloating === null) return null; + return zonedPartsToInstant(floatingDateToLocalParts(nextFloating), row.timezone).toISOString(); + } catch { + return null; + } +} + function toRuntimeUpdate(state: ReminderRuntimeState): LocalReminderRuntimeUpdate { return { reminder_disposition_state: state.reminder_disposition_state, diff --git a/frontend/tests/integration/sqliteReminderStateStore.test.ts b/frontend/tests/integration/sqliteReminderStateStore.test.ts new file mode 100644 index 00000000..018b057c --- /dev/null +++ b/frontend/tests/integration/sqliteReminderStateStore.test.ts @@ -0,0 +1,170 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; +import { SqliteReminderStateStore } from '../../src/features/reminder/data/local/SqliteReminderStateStore'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +const START_TIME = '2026-08-10T07:00:00Z'; // 2026-08-10 15:00 Asia/Shanghai (Monday) +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1_000; + +function recurringSchedule(overrides: Partial = {}): CloudScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'recurring', + title: '周会', + is_all_day: 0, + start_time: START_TIME, + end_time: null, + timezone: 'Asia/Shanghai', + // Asia/Shanghai 没有 DST,整周推进不用担心跨夏令时的偏差。 + recurrence_rule: 'FREQ=WEEKLY;COUNT=4', + location_name: null, + latitude: null, + longitude: null, + reminder_type: null, + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: null, + reminder_disposition_state: null, + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-09T00:00:00Z', + ...overrides, + }; +} + +describe('SqliteReminderStateStore', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + let store: SqliteReminderStateStore; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + store = new SqliteReminderStateStore(); + store.attach(repository, 'account-a'); + }); + + afterEach(() => { + database.close(); + vi.useRealTimers(); + }); + + it('returns null before attach()', async () => { + const detached = new SqliteReminderStateStore(); + expect(await detached.read('schedule-a')).toBeNull(); + }); + + it('passes through a once schedule unchanged, including a null next_trigger_at', async () => { + await repository.applyCloudSchedule( + recurringSchedule({ schedule_kind: 'once', recurrence_rule: null }), + ); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBeNull(); + expect(runtime?.reminder_disposition_state).toBeNull(); + }); + + it('leaves a recurring schedule alone once it already has an occurrence cursor', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: '2026-08-17T07:00:00Z', + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-17T07:00:00Z', + sync_status: 'synced', + }); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBe('2026-08-17T07:00:00Z'); + expect(runtime?.reminder_disposition_state).toBe('pending'); + }); + + it('computes the next occurrence and resets disposition when the cursor is null', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + // confirmInternal 的实际写法:确认之后把 disposition 设成 confirmed,同时把 + // occurrence 光标清空,这就是它触发"该往前挪一格了"的方式。 + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-10T07:00:00Z', + sync_status: 'synced', + }); + // "现在" 落在第 2 次和第 3 次发生之间:应该拿到第 3 次,不是回退到 start_time + // 或者停在第 2 次。 + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 1.5)); + + const runtime = await store.read('schedule-a'); + + const expectedThirdOccurrence = new Date( + Date.parse(START_TIME) + ONE_WEEK_MS * 2, + ).toISOString(); + expect(runtime?.next_trigger_at).toBe(expectedThirdOccurrence); + // 新 occurrence 的 disposition 必须重置,否则 canDeliver() 会因为上一次是 + // confirmed 就永远拦住这条日程,重复提醒响一次之后就再也不会响了。 + expect(runtime?.reminder_disposition_state).toBeNull(); + expect(runtime?.disposition_updated_at).toBeNull(); + }); + + it('leaves the cursor null once the recurring series has run out of occurrences', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-31T07:00:00Z', + sync_status: 'synced', + }); + // COUNT=4:第 4 次之后系列结束,"现在"设在最后一次之后。 + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 10)); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBeNull(); + }); + + it('write() persists a runtime patch and setDisposition() preserves the current cursor', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await store.write('schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: '2026-08-17T07:00:00Z', + snoozed_until: null, + geofence_armed: false, + disposition_updated_at: '2026-08-17T07:00:00Z', + sync_status: 'pending', + recorded_location: null, + }); + + await store.setDisposition('schedule-a', { + schedule_id: 'schedule-a', + state: 'confirmed', + updated_at: '2026-08-17T07:05:00Z', + snoozed_until: null, + sync_status: 'pending', + }); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.reminder_disposition_state).toBe('confirmed'); + // setDisposition 本身不该动 occurrence 光标,只有 confirmInternal 自己那次 + // 显式的 write() 才会把它清空。 + expect(stored?.next_trigger_at).toBe('2026-08-17T07:00:00Z'); + }); +}); From d620ad933f387ff29e7e520b6d8d3f3502591cbc Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 12:10:04 +0800 Subject: [PATCH 4/9] fix(reminder): don't drop headless geofence events; batch rebuild() geofenceTask.ts emitted straight to subscribers and nothing else. When Android launches the JS engine headlessly to run just this TaskManager task (app process killed, no React tree mounted), ExpoLocationMonitor was never constructed, so the listener set was empty and the enter/exit event was silently discarded -- a location reminder armed while the app was dead would just never fire. Events now persist to expo-sqlite/kv-store when there are no subscribers, and get drained and replayed once a real session mounts (in both watch() and rebuild(), so either an incremental registration or a full startup rebuild picks them up). The kv-store import has to be lazy (dynamic import inside the two functions that need it, not a top-level import) -- its default export is a singleton constructed at module load, which throws in Jest where no real native module exists; this matches the lazy-import pattern already used for native-backed ports elsewhere (ExpoAudioPlayback.ts). rebuild() also called watch() once per target, so N targets meant N separate startGeofencingAsync calls (O(N^2) total registered regions) plus N redundant location fixes for what's a single rebuild pass. It now populates the watch maps directly, syncs geofencing once, and fans one location fix out to all newly-registered listeners. --- .../location/ExpoLocationMonitor.ts | 54 ++++++++++++++----- .../infrastructure/location/geofenceTask.ts | 53 ++++++++++++++++++ 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts index cae3847f..12a2081f 100644 --- a/frontend/src/infrastructure/location/ExpoLocationMonitor.ts +++ b/frontend/src/infrastructure/location/ExpoLocationMonitor.ts @@ -12,6 +12,7 @@ import type { LocationSample } from '../../features/reminder/domain'; import { GEOFENCE_TASK_NAME, + drainPendingGeofenceEvents, subscribeGeofenceTaskEvents, type GeofenceTaskPayload, } from './geofenceTask'; @@ -71,6 +72,7 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide if (sample != null) { listener({ schedule_id: request.schedule_id, sample, phase: 'inside' }); } + await this.replayPendingEvents(); return { listener_id, schedule_id: request.schedule_id }; } @@ -86,23 +88,41 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide for (const listenerId of [...this.watches.keys()]) { await this.removeWatch(listenerId, false); } - await this.enqueueSync(); + // 直接灌 Map,不逐个调用 watch():watch() 自己的 enqueueSync()+getCurrentSample() + // 是为单条增量注册设计的,N 个 target 各跑一次会把 startGeofencingAsync 的区域 + // 列表越注册越长(O(N²) 总区域数)、定位请求也打 N 次;这里全部灌完只同步一次、 + // 取一次定位,再扇给每个刚注册的 watch。 const handles: LocationWatchHandle[] = []; for (const target of targets) { - handles.push( - await this.watch( - { - schedule_id: target.schedule_id, - center: target.center, - radius_meters: target.radius_meters, - mode: target.mode, - background: target.background, - }, - listener, - ), - ); + const listener_id = `location-${target.schedule_id}`; + this.watches.set(listener_id, { + listener_id, + request: { + schedule_id: target.schedule_id, + center: target.center, + radius_meters: target.radius_meters, + mode: target.mode, + background: target.background, + }, + listener, + }); + this.scheduleToListener.set(target.schedule_id, listener_id); + handles.push({ listener_id, schedule_id: target.schedule_id }); } + await this.enqueueSync(); + + const sample = await this.getCurrentSample(); + if (sample != null) { + for (const handle of handles) { + listener({ schedule_id: handle.schedule_id, sample, phase: 'inside' }); + } + } + + // App 进程被杀掉期间,headless task 攒下的围栏事件在这里补上——watches/ + // scheduleToListener 刚灌好,handleTaskEvent 才查得到对应的 watch。 + await this.replayPendingEvents(); + return handles; } @@ -137,6 +157,14 @@ export class ExpoLocationMonitor implements LocationMonitorPort, LocationProvide void this.enqueueSync(); }; + /** 补上 headless task 期间(没有订阅者时)攒下的围栏事件,按正常路径重放一遍。 */ + private async replayPendingEvents(): Promise { + const pending = await drainPendingGeofenceEvents(); + for (const payload of pending) { + this.handleTaskEvent(payload); + } + } + private readonly handleTaskEvent = (payload: GeofenceTaskPayload): void => { const listenerId = this.scheduleToListener.get(payload.schedule_id); const watch = listenerId != null ? this.watches.get(listenerId) : undefined; diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts index 55cffa10..7f9369df 100644 --- a/frontend/src/infrastructure/location/geofenceTask.ts +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -16,6 +16,11 @@ type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void; const listeners = new Set(); +/** 没有订阅者时(App 进程已死,Expo 只把 JS 引擎拉起来跑这一个 headless task, + * AppRoot/ExpoLocationMonitor 那套 React 生命周期根本没启动)事件会先落这里,等 + * 真正的会话起来后由 drainPendingGeofenceEvents() 取走重放,而不是直接丢掉。 */ +const PENDING_EVENTS_KEY = 'timeflow-pending-geofence-events'; + /** 订阅系统围栏任务回调;须在应用入口尽早 import 本模块以完成 defineTask。 */ export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () => void { listeners.add(listener); @@ -24,7 +29,55 @@ export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () }; } +/** 懒加载:expo-sqlite/kv-store 的默认导出是模块加载时就构造的单例,顶层 import + * 会在测试环境(没有真实原生模块)里直接抛错——这个仓库里原生相关的按需依赖 + * 一律走动态 import,参照 ExpoAudioPlayback.ts 的 loadExpoAudio()。 */ +async function loadStorage(): Promise { + try { + const mod = await import('expo-sqlite/kv-store'); + return mod.Storage; + } catch { + return null; + } +} + +/** 取出并清空 headless 期间攒下的围栏事件;调用方负责按订阅时的逻辑重放它们。 */ +export async function drainPendingGeofenceEvents(): Promise { + const storage = await loadStorage(); + if (storage == null) return []; + const raw = await storage.getItem(PENDING_EVENTS_KEY); + if (raw == null) return []; + await storage.removeItem(PENDING_EVENTS_KEY); + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as GeofenceTaskPayload[]) : []; + } catch { + return []; + } +} + +async function persistPendingEvent(payload: GeofenceTaskPayload): Promise { + const storage = await loadStorage(); + if (storage == null) return; + const raw = await storage.getItem(PENDING_EVENTS_KEY); + const pending: GeofenceTaskPayload[] = []; + if (raw != null) { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) pending.push(...(parsed as GeofenceTaskPayload[])); + } catch { + // 上一份坏了就当没有,不阻塞这次事件的持久化。 + } + } + pending.push(payload); + await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending)); +} + function emit(payload: GeofenceTaskPayload): void { + if (listeners.size === 0) { + void persistPendingEvent(payload); + return; + } for (const listener of listeners) { listener(payload); } From 7528383617d03ca5cf13f8173827aa3156ffc7ad Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 12:10:18 +0800 Subject: [PATCH 5/9] fix(reminder): reschedule native alarms after reboot or app update AlarmManager registrations are cleared by the OS on both BOOT_COMPLETED and MY_PACKAGE_REPLACED. AlarmScheduler already persisted every alarm record to SharedPreferences, but nothing ever reloaded and re-armed them -- every pending reminder went silent until the user happened to open the app and trigger a rebuild. Added a BootReceiver for both actions, and AlarmScheduler.rescheduleAfterBoot() to reload persisted records and re-arm each one that's still in the future (same PendingIntent/alarmId, not a fresh one -- schedule()'s own re-arming logic is now shared via a new rearm() helper instead of duplicated). Already-expired records are dropped rather than fired here: LocalReminderApplication's own JS-side catch-up already delivers overdue reminders once the app reopens, and firing them again natively would deliver the same reminder twice. Verified via a real Gradle build: :timeflow-alarm:compileDebugJavaWithJavac succeeds, and :app:processDebugMainManifest shows BootReceiver correctly merged into the app's final manifest with its intent-filter. --- .../android/src/main/AndroidManifest.xml | 10 ++++ .../com/timeflow/alarm/AlarmScheduler.java | 55 +++++++++++++++---- .../java/com/timeflow/alarm/BootReceiver.java | 21 +++++++ 3 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java diff --git a/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml index 5aa4f921..f1e89a38 100644 --- a/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml +++ b/frontend/modules/timeflow-alarm/android/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + + + + + + + * 已经过期的记录直接从持久化列表里丢弃,不在这里补响——JS 侧 + * {@code LocalReminderApplication} 自己会在下次 rebuild 时把错过的提醒当到点处理, + * 这里再触发一次会导致同一条提醒响两次。 + */ + public static void rescheduleAfterBoot(Context context) { + AlarmManager alarmManager = + (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + return; + } + boolean canScheduleExact = android.os.Build.VERSION.SDK_INT + < android.os.Build.VERSION_CODES.S + || alarmManager.canScheduleExactAlarms(); + + long now = System.currentTimeMillis(); + List survivors = new ArrayList<>(); + for (AlarmRecord record : loadAlarms(context)) { + if (record.triggerAtMillis <= now || !canScheduleExact) { + continue; + } + rearm(context, alarmManager, record); + survivors.add(record); + } + saveAlarms(context, survivors); + } + + private static void rearm(Context context, AlarmManager alarmManager, AlarmRecord record) { PendingIntent operation = buildAlarmBroadcastPendingIntent( context, record, @@ -110,16 +155,6 @@ public static String schedule( new AlarmManager.AlarmClockInfo(record.triggerAtMillis, showPendingIntent), operation ); - - alarms.add(record); - saveAlarms(context, alarms); - return alarmId; - } - - /** @deprecated 请改用 {@link #schedule(Context, long, String, String)}。 */ - @Deprecated - public static String schedule(Context context, long triggerAtMillis, String title) { - return schedule(context, triggerAtMillis, title, ""); } public static boolean cancel(Context context, String alarmId) { diff --git a/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java new file mode 100644 index 00000000..6af9dbf7 --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/BootReceiver.java @@ -0,0 +1,21 @@ +package com.timeflow.alarm; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * 重新挂上系统重启 / 应用更新后被清空的 AlarmManager 注册。 + * MY_PACKAGE_REPLACED 覆盖应用更新场景——同样会清空 AlarmManager,行为跟重启一致。 + */ +public final class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent == null ? null : intent.getAction(); + if (!Intent.ACTION_BOOT_COMPLETED.equals(action) + && !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) { + return; + } + AlarmScheduler.rescheduleAfterBoot(context); + } +} From aa1281ac9684b3c39980488018d2d8f73a978e7f Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 13:23:51 +0800 Subject: [PATCH 6/9] fix(location): reuse recent position during voice handshake --- .../gateway/websocket/handlers/session.py | 8 +++ .../external/location/tencent_maps.py | 22 +++++- .../timeflow/intelligence/location/tools.py | 9 ++- .../timeflow/intelligence/realtime/agent.py | 14 +++- .../intelligence/realtime/schedule_tools.py | 13 +++- backend/src/timeflow/main.py | 10 +++ .../AssistantContinuousConversationService.ts | 11 ++- .../AssistantConversationService.ts | 11 ++- .../location/ExpoLocationProvider.ts | 69 ++++++++++++++++--- 9 files changed, 148 insertions(+), 19 deletions(-) diff --git a/backend/src/timeflow/gateway/websocket/handlers/session.py b/backend/src/timeflow/gateway/websocket/handlers/session.py index 6a1468e0..682d05fe 100644 --- a/backend/src/timeflow/gateway/websocket/handlers/session.py +++ b/backend/src/timeflow/gateway/websocket/handlers/session.py @@ -1,5 +1,6 @@ """The session.hello handshake that opens an authenticated session.""" +import logging from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime @@ -25,6 +26,7 @@ DEFAULT_TIMEZONE = "Asia/Shanghai" DEFAULT_VOICE_MODE = "push_to_talk" _VOICE_MODES = frozenset({"push_to_talk", "continuous"}) +logger = logging.getLogger(__name__) @dataclass(frozen=True, slots=True) @@ -105,6 +107,12 @@ def perform( timezone=_resolved_timezone(hello.payload.timezone), voice_mode=_resolved_voice_mode(hello.payload.voice_mode), ) + logger.info( + "voice session authenticated: voice_mode=%s location_available=%s coordinate_system=%s", + session.voice_mode, + session.latitude is not None and session.longitude is not None, + session.coordinate_system, + ) reply = SessionReady( request_id=hello.request_id, payload=SessionReadyPayload( diff --git a/backend/src/timeflow/infrastructure/external/location/tencent_maps.py b/backend/src/timeflow/infrastructure/external/location/tencent_maps.py index b8dd28df..2415887c 100644 --- a/backend/src/timeflow/infrastructure/external/location/tencent_maps.py +++ b/backend/src/timeflow/infrastructure/external/location/tencent_maps.py @@ -93,6 +93,7 @@ async def search( candidate = _candidate(item) if candidate is not None: candidates.append(candidate) + logger.info("Tencent Maps search succeeded: candidate_count=%s", len(candidates)) return tuple(candidates) async def _get( @@ -106,12 +107,26 @@ async def _get( response = await self._client.get(f"{self._base_url}{path}", params=params) response.raise_for_status() payload = response.json() - except (httpx.RequestError, httpx.HTTPStatusError): - logger.warning("Tencent Maps request failed", extra={"operation": operation}) + except httpx.HTTPStatusError as error: + logger.warning( + "Tencent Maps request failed: operation=%s error_type=%s status_code=%s", + operation, + type(error).__name__, + error.response.status_code, + ) + raise LocationConnectionError("Tencent Maps request failed") from None + except httpx.RequestError as error: + logger.warning( + "Tencent Maps request failed: operation=%s error_type=%s", + operation, + type(error).__name__, + ) raise LocationConnectionError("Tencent Maps request failed") from None except ValueError: + logger.warning("Tencent Maps returned invalid JSON: operation=%s", operation) raise LocationProtocolError("Tencent Maps returned invalid JSON") from None if not isinstance(payload, dict): + logger.warning("Tencent Maps returned invalid response: operation=%s", operation) raise LocationProtocolError("Tencent Maps returned an invalid response") status = payload.get("status") if isinstance(status, bool) or not isinstance(status, int) or status != 0: @@ -119,7 +134,8 @@ async def _get( # contains user data, so logging them is safe and is the only way to tell # these apart later -- LocationProtocolError itself carries no detail on. logger.warning( - "Tencent Maps rejected the request: status=%s message=%s", + "Tencent Maps rejected the request: operation=%s status=%s message=%s", + operation, status, payload.get("message"), ) diff --git a/backend/src/timeflow/intelligence/location/tools.py b/backend/src/timeflow/intelligence/location/tools.py index a7176944..2ac074d0 100644 --- a/backend/src/timeflow/intelligence/location/tools.py +++ b/backend/src/timeflow/intelligence/location/tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from collections.abc import Mapping from dataclasses import dataclass @@ -18,6 +19,7 @@ from timeflow.intelligence.location.service import LocationSearchService LOCATION_SEARCH = "location_search" +logger = logging.getLogger(__name__) @dataclass(frozen=True, slots=True) @@ -33,10 +35,13 @@ async def execute(self, arguments: Mapping[str, object]) -> str: try: query = _query(arguments) candidates = await self.service.search(self.context, query) - except LocationInputError: + except LocationInputError as error: + logger.warning("location search rejected input: error_type=%s", type(error).__name__) return _json({"status": "invalid_input", "candidates": []}) - except (LocationConfigurationError, LocationConnectionError, LocationProtocolError): + except (LocationConfigurationError, LocationConnectionError, LocationProtocolError) as error: + logger.warning("location search provider unavailable: error_type=%s", type(error).__name__) return _json({"status": "provider_unavailable", "candidates": []}) + logger.info("location search completed: candidate_count=%s", len(candidates)) return _json( { "status": "ok", diff --git a/backend/src/timeflow/intelligence/realtime/agent.py b/backend/src/timeflow/intelligence/realtime/agent.py index 132eb051..6335186a 100644 --- a/backend/src/timeflow/intelligence/realtime/agent.py +++ b/backend/src/timeflow/intelligence/realtime/agent.py @@ -229,8 +229,19 @@ async def _session_for(self, key: tuple[str, str], stream: StreamInfo) -> _Held account_id, _ = key timezone = stream.timezone voice_mode = stream.voice_mode + client_location = _client_location_from_stream(stream) + logger.info( + "opening realtime session: voice_mode=%s location_fields_complete=%s " + "location_valid=%s coordinate_system=%s", + voice_mode, + stream.latitude is not None + and stream.longitude is not None + and stream.coordinate_system is not None, + client_location is not None, + stream.coordinate_system, + ) tools = ( - await self._tools_factory(account_id, timezone, _client_location_from_stream(stream)) + await self._tools_factory(account_id, timezone, client_location) if self._tools_factory is not None else None ) @@ -389,6 +400,7 @@ async def tool_requested(self, call_id: str, name: str, arguments: dict[str, Any ) return + logger.info("realtime model requested tool: name=%s", name) result = await self._tools.run(name, arguments) if result.outcome is not None: outcome = result.outcome diff --git a/backend/src/timeflow/intelligence/realtime/schedule_tools.py b/backend/src/timeflow/intelligence/realtime/schedule_tools.py index 0b2a7db0..870b65b8 100644 --- a/backend/src/timeflow/intelligence/realtime/schedule_tools.py +++ b/backend/src/timeflow/intelligence/realtime/schedule_tools.py @@ -344,12 +344,23 @@ async def _location_search(self, arguments: dict[str, Any]) -> ToolResult: provider recovers. """ if self._location_service is None or self._client_location is None: + logger.warning( + "location search unavailable: provider_configured=%s client_location_available=%s", + self._location_service is not None, + self._client_location is not None, + ) return ToolResult(output=PROVIDER_UNAVAILABLE_RESULT) if self._location_context is None: try: + logger.info("preparing location search context") self._location_context = await self._location_service.prepare(self._client_location) - except LocationError: + except LocationError as error: + logger.warning( + "location search context preparation failed: error_type=%s", + type(error).__name__, + ) return ToolResult(output=PROVIDER_UNAVAILABLE_RESULT) + logger.info("location search context prepared") tool = build_location_search_tool(self._location_service, self._location_context) return ToolResult(output=await tool.execute(arguments)) diff --git a/backend/src/timeflow/main.py b/backend/src/timeflow/main.py index f7f800cb..3ec55f16 100644 --- a/backend/src/timeflow/main.py +++ b/backend/src/timeflow/main.py @@ -101,6 +101,16 @@ def create_app( owned_http_client, settings.tencent_map_api_key, settings.tencent_map_base_url ) ) + logger.info( + "location search configured: provider=tencent_maps timeout_seconds=%s", + settings.tencent_map_timeout_seconds, + ) + elif audio_sink is None: + logger.warning( + "location search unavailable at startup: voice_agent_mode=%s tencent_maps_configured=%s", + settings.voice_agent_mode, + settings.tencent_maps_is_configured(), + ) @asynccontextmanager async def lifespan(_application: FastAPI) -> AsyncIterator[None]: diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 18e84ab6..e6451201 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -286,13 +286,22 @@ export class AssistantContinuousConversationService implements AssistantApplicat // (尤其冷启动 GPS)可能耗时数秒,放在连接之后拿会把这段时间算进握手预算, // 导致 hello 送达前就被服务端以 1008 断开。超时兜底,拿不到就不带,不阻塞握手。 let timeoutId: ReturnType | undefined; + let locationTimedOut = false; const sample = await Promise.race([ this.deps.location.getCurrentSample().catch(() => null), new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(null), LOCATION_TIMEOUT_MS); + timeoutId = setTimeout(() => { + locationTimedOut = true; + resolve(null); + }, LOCATION_TIMEOUT_MS); }), ]); clearTimeout(timeoutId); + if (sample === null) { + console.warn('[location-search] voice handshake has no location', { + reason: locationTimedOut ? 'timeout' : 'unavailable', + }); + } // session.hello → session.ready 的握手已经在 transport.connect() 内部完成 // (共享的 AuthenticatedWebSocketClient 负责,voice_mode 已经绑定在这个 diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index c58bfbe7..85e3a60b 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -180,13 +180,22 @@ export class AssistantConversationService implements AssistantApplicationPort { // 清掉超时定时器,不然赢了比赛的那次调用还会留一个挂到 2s 之后才触发的 // 空转定时器(无功能影响,但测试环境里会被当成没清理干净的异步句柄)。 let timeoutId: ReturnType | undefined; + let locationTimedOut = false; const sample = await Promise.race([ this.deps.location.getCurrentSample().catch(() => null), new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(null), LOCATION_TIMEOUT_MS); + timeoutId = setTimeout(() => { + locationTimedOut = true; + resolve(null); + }, LOCATION_TIMEOUT_MS); }), ]); clearTimeout(timeoutId); + if (sample === null) { + console.warn('[location-search] voice handshake has no location', { + reason: locationTimedOut ? 'timeout' : 'unavailable', + }); + } // session.hello → session.ready 的握手已经在 transport.connect() 内部完成 // (共享的 AuthenticatedWebSocketClient 负责),这里拿到的就是已经 ready 的连接。 diff --git a/frontend/src/infrastructure/location/ExpoLocationProvider.ts b/frontend/src/infrastructure/location/ExpoLocationProvider.ts index f9907df1..c0ce1b77 100644 --- a/frontend/src/infrastructure/location/ExpoLocationProvider.ts +++ b/frontend/src/infrastructure/location/ExpoLocationProvider.ts @@ -4,27 +4,76 @@ import type { LocationSample } from '../../features/reminder/domain'; import type { LocationProvider } from './LocationProvider'; +const LAST_KNOWN_MAX_AGE_MS = 60_000; +const LAST_KNOWN_REQUIRED_ACCURACY_METERS = 200; + /** - * 真实定位实现。权限被拒绝或定位失败都返回 null 而不是抛错——调用方(目前是 - * session.hello 的位置字段)拿不到就不带这个字段,不应该因为定位失败连不上。 + * 真实定位实现。语音握手优先使用短时间内的缓存位置,避免等待 GPS 冷启动;同时 + * 在后台刷新当前位置,让下一次连接获得更新的位置。权限被拒绝或定位失败都返回 + * null 而不是抛错,调用方拿不到就不带 session.hello 的位置字段,不影响连通性。 */ export class ExpoLocationProvider implements LocationProvider { + private freshSampleInFlight: Promise | null = null; + async getCurrentSample(): Promise { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { + console.warn('[location-search] foreground location permission is unavailable', { status }); return null; } + const cached = await this.getRecentSample(); + if (cached !== null) { + void this.getFreshSample(); + return cached; + } + + return this.getFreshSample(); + } + + private async getRecentSample(): Promise { try { - const position = await Location.getCurrentPositionAsync({}); - return { - accuracy_meters: position.coords.accuracy ?? 0, - latitude: position.coords.latitude, - longitude: position.coords.longitude, - observed_at: new Date(position.timestamp).toISOString(), - }; - } catch { + const position = await Location.getLastKnownPositionAsync({ + maxAge: LAST_KNOWN_MAX_AGE_MS, + requiredAccuracy: LAST_KNOWN_REQUIRED_ACCURACY_METERS, + }); + return position === null ? null : toSample(position); + } catch (error) { + console.warn('[location-search] failed to read cached location', { + errorType: error instanceof Error ? error.name : typeof error, + }); return null; } } + + private getFreshSample(): Promise { + if (this.freshSampleInFlight !== null) { + return this.freshSampleInFlight; + } + + const request = Location.getCurrentPositionAsync({}) + .then(toSample) + .catch((error) => { + console.warn('[location-search] failed to acquire current location', { + errorType: error instanceof Error ? error.name : typeof error, + }); + return null; + }); + this.freshSampleInFlight = request; + void request.finally(() => { + if (this.freshSampleInFlight === request) { + this.freshSampleInFlight = null; + } + }); + return request; + } +} + +function toSample(position: Location.LocationObject): LocationSample { + return { + accuracy_meters: position.coords.accuracy ?? 0, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + observed_at: new Date(position.timestamp).toISOString(), + }; } From 3eb54e59619265977c38bad576f9dcfed4bf0ccf Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 14:15:57 +0800 Subject: [PATCH 7/9] fix(voice): preserve new TTS after interruption --- .../timeflow/intelligence/realtime/agent.py | 14 +- .../realtime/test_realtime_agent.py | 7 +- .../AssistantContinuousConversationService.ts | 50 ++++++- ...stantContinuousConversationService.test.ts | 131 ++++++++++++++++++ 4 files changed, 189 insertions(+), 13 deletions(-) diff --git a/backend/src/timeflow/intelligence/realtime/agent.py b/backend/src/timeflow/intelligence/realtime/agent.py index 6335186a..24abe511 100644 --- a/backend/src/timeflow/intelligence/realtime/agent.py +++ b/backend/src/timeflow/intelligence/realtime/agent.py @@ -338,6 +338,9 @@ def __init__( self._question_id_factory = question_id_factory # None between replies; assigned on first use so each reply gets a fresh id. self._audio_id: str | None = None + # 保留最近一次已经交给客户端播放的 id。模型生成通常快于手机播放:等模型 + # 侧交付完成后再发生的打断,仍必须准确指出需要停止的是哪条旧音频。 + self._last_audio_id: str | None = None self._reply_id: str | None = None self._spoken = "" self._purpose = REPLY_PURPOSE @@ -384,6 +387,7 @@ async def audio(self, data: bytes) -> None: """Queue one chunk, starting the delivery on the first one.""" if self._speaking is None: self._audio_id = self._audio_id_factory() + self._last_audio_id = self._audio_id self._speaking = asyncio.create_task(self._speak()) await self._audio.put(data) @@ -488,10 +492,12 @@ async def _finish_reply(self, *, canceled: bool) -> None: # own before the barge-in arrived -- but the model generates audio faster # than it plays back, so the phone can still be sounding it out. The session # only calls interrupted() this late when its own playable-until estimate - # says that's still plausible, so the client is told to stop regardless of - # what this turn still has queued locally. audio_id is empty because there - # is no reply left here to name; the client's handler does not read it. - await self._result_sink.deliver_canceled(AudioCanceled(audio_id=""), self._stream) + # says that's still plausible. Preserve the original id so the client can + # distinguish this old cancellation from a newer reply that has just begun. + if self._last_audio_id is not None: + await self._result_sink.deliver_canceled( + AudioCanceled(audio_id=self._last_audio_id), self._stream + ) self._spoken = "" self._reply_id = None self._audio_id = None diff --git a/backend/tests/intelligence/realtime/test_realtime_agent.py b/backend/tests/intelligence/realtime/test_realtime_agent.py index 14043b58..70586811 100644 --- a/backend/tests/intelligence/realtime/test_realtime_agent.py +++ b/backend/tests/intelligence/realtime/test_realtime_agent.py @@ -495,8 +495,8 @@ def test_a_barge_in_after_the_reply_already_finished_sending_still_tells_the_cli back, so a reply can finish sending -- turn_completed() already settled it -- while the phone is still sounding it out. The session only calls interrupted() this late when its own playable-until estimate says that is still plausible, so the client - must still be told to stop even though this turn's own bookkeeping has nothing left - to name; there is no reply id left here to attach to it, hence the empty audio_id. + must still be told to stop even though this turn's current bookkeeping has already + been reset. The original audio id must be preserved so a newer reply is not stopped. """ async def scenario() -> None: @@ -515,7 +515,8 @@ async def scenario() -> None: ) (canceled,) = [payload for kind, payload in sink.calls if kind == "canceled"] - assert canceled.audio_id == "" + (audio_reply,) = [payload for kind, payload in sink.calls if kind == "audio_start"] + assert canceled.audio_id == audio_reply.audio_id asyncio.run(scenario()) diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index e6451201..5dd367f0 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -52,6 +52,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat private streamId: string | null = null; /** 非 null 表示当前正处于 voice.tts.start 和 voice.tts.end/canceled 之间。 */ private currentAudioId: string | null = null; + /** 最近被打断的音频 id;服务端会在 canceled 后补发同 id 的 tts.end。 */ + private canceledAudioId: string | null = null; private streamStartedWaiter: ((conversationId: string) => void) | null = null; /** 跟 streamStartedWaiter 配对;传输层报错/连接掉线时用它让等待方结束,不然会永远卡住。 */ private streamStartRejecter: ((error: Error) => void) | null = null; @@ -81,6 +83,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat * startStream() 内部有一个没人等的 await(配置原生播放器),如果紧跟着的 * 第一块音频不排在它后面,可能在原生侧还没配置完时就到达。 */ private playbackChain: Promise = Promise.resolve(); + /** 取消时递增,让已经排队但尚未执行的旧流操作失效。 */ + private playbackGeneration = 0; private readonly unsubscribeAppState: () => void; constructor( @@ -237,7 +241,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.replyText = null; this.currentAudioId = null; this.notifyListeners(); - await this.deps.playback.stop().catch(() => {}); + await this.stopPlaybackImmediately(); } /** 用户点圆圈暂停/恢复。暂停期间空闲计时器照常跑——忘记恢复也会兜底挂断。 */ @@ -371,6 +375,8 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.notifyListeners(); return; case 'voice.tts.start': + this.playbackGeneration += 1; + this.canceledAudioId = null; this.currentAudioId = message.audio_id; this.setState({ conversationId: message.conversation_id, phase: 'speaking' }); this.chainPlayback(() => @@ -381,7 +387,16 @@ export class AssistantContinuousConversationService implements AssistantApplicat ); return; case 'voice.tts.end': + // canceled 后服务端仍会补发同一条 tts.end;它不能收尾新流,也不能把 + // interrupted 状态提前改回 listening。 + if ( + (this.canceledAudioId !== null && message.audio_id === this.canceledAudioId) || + (this.currentAudioId !== null && message.audio_id !== this.currentAudioId) + ) { + return; + } this.currentAudioId = null; + this.canceledAudioId = null; this.chainPlayback(() => this.deps.playback.endStream()); this.setState({ conversationId: message.conversation_id, phase: 'listening' }); // 播报完成后给一个全新的窗口,对应"播报完成后进入短暂等待"——不用单独 @@ -389,10 +404,17 @@ export class AssistantContinuousConversationService implements AssistantApplicat this.armIdleTimer(); return; case 'voice.tts.canceled': - // 用户开口打断了正在播的回复:立刻丢掉播放端缓冲里还没放出来的音频, - // 而不是等 voice.tts.end(后面仍会补发,但语义已经不是"正常说完")。 + // 用户开口打断了正在播的回复:stop 必须绕过 playbackChain 立即执行, + // 否则已排队的 PCM 会先继续喂给原生播放器;旧队列随后由代次检查丢弃。 + if ( + this.currentAudioId !== null && + (message.audio_id === '' || message.audio_id !== this.currentAudioId) + ) { + return; + } + this.canceledAudioId = message.audio_id || this.currentAudioId; this.currentAudioId = null; - this.chainPlayback(() => this.deps.playback.stop()); + void this.stopPlaybackImmediately(); this.setState({ conversationId: message.conversation_id, phase: 'interrupted' }); return; case 'voice.session.end': @@ -431,9 +453,25 @@ export class AssistantContinuousConversationService implements AssistantApplicat } /** 把一次对原生播放模块的调用接到 playbackChain 末尾,保证上一次真正执行完 - * (不管成功与否)才轮到这一次。 */ + * (不管成功与否)才轮到这一次;取消后旧代次的操作会被跳过。 */ private chainPlayback(run: () => Promise): void { - this.playbackChain = this.playbackChain.then(run).catch(() => {}); + const generation = this.playbackGeneration; + this.playbackChain = this.playbackChain + .then(async () => { + if (generation !== this.playbackGeneration) { + return; + } + await run(); + }) + .catch(() => {}); + } + + /** 立即清空原生播放器,并把后续新操作排在 stop 完成之后。 */ + private async stopPlaybackImmediately(): Promise { + this.playbackGeneration += 1; + const stop = this.deps.playback.stop().catch(() => {}); + this.playbackChain = stop; + await stop; } private handleClose(event: { code: number; reason: string }): void { diff --git a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts index 221c66f6..814b8571 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantContinuousConversationService.test.ts @@ -444,6 +444,137 @@ describe('AssistantContinuousConversationService', () => { service.dispose(); }); + it('stops immediately and drops queued chunks when TTS is canceled', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps); + const calls: string[] = []; + let resolveFirst: () => void = () => {}; + (deps.playback.pushChunk as jest.Mock) + .mockImplementationOnce( + () => + new Promise((resolve) => { + calls.push('push-1'); + resolveFirst = resolve; + }), + ) + .mockImplementationOnce(async () => { + calls.push('push-2'); + }); + (deps.playback.stop as jest.Mock).mockImplementation(async () => { + calls.push('stop'); + }); + + await startListening(fake, service); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + await flushAsync(); + fake.emitAudioFrame(new ArrayBuffer(4)); + fake.emitAudioFrame(new ArrayBuffer(4)); + await flushAsync(); + + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + expect(calls).toEqual(['push-1', 'stop']); + + resolveFirst(); + await flushAsync(); + + expect(calls).toEqual(['push-1', 'stop']); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' }); + service.dispose(); + }); + + it('ignores the canceled stream end that arrives after interruption', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps); + + await startListening(fake, service); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.end', + } as AssistantServerMessage); + await flushAsync(); + + expect(deps.playback.endStream).not.toHaveBeenCalled(); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'interrupted' }); + service.dispose(); + }); + + it('does not stop a newer TTS when a late cancellation belongs to the old audio', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantContinuousConversationService({ accountId: 'acc_001' }, deps); + + await startListening(fake, service); + const startMessage = (audioId: string): AssistantServerMessage => + ({ + audio_id: audioId, + conversation_id: 'conv_001', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + }) as AssistantServerMessage; + + fake.emitMessage(startMessage('audio_001')); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.end', + } as AssistantServerMessage); + fake.emitMessage(startMessage('audio_002')); + fake.emitMessage({ + audio_id: 'audio_001', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + // 兼容尚未升级的后端:旧实现会把这种晚到的取消发成空 id,不能把新流停掉。 + fake.emitMessage({ + audio_id: '', + conversation_id: 'conv_001', + type: 'voice.tts.canceled', + } as AssistantServerMessage); + await flushAsync(); + + expect(deps.playback.stop).not.toHaveBeenCalled(); + expect(service.getState()).toEqual({ conversationId: 'conv_001', phase: 'speaking' }); + service.dispose(); + }); + it('handleClose() unsubscribes from the shared connection before nulling it, even when a real disconnect races endTurn()', async () => { const fake = createFakeConnection(); const deps = createDeps({ connection: fake.connection }); From af98b3fd3fcf20d62e6f0b87eeba5694d8076d6c Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 14:24:22 +0800 Subject: [PATCH 8/9] fix(voice): remove stale push-to-talk listeners --- .../AssistantConversationService.ts | 4 +++ .../AssistantConversationService.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index 85e3a60b..da30af94 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -297,6 +297,10 @@ export class AssistantConversationService implements AssistantApplicationPort { } private handleClose(event: { code: number; reason: string }): void { + // 从按住说话切到连续对话时,共享 WS 会因 voiceMode 不同而主动断开重连。 + // 旧连接若只把 unsubscribeConnection 置空却不执行,旧服务仍会订阅新连接的 + // TTS/PCM,并与连续对话服务把同一句话重复送进播放器。 + this.unsubscribeConnection?.(); this.connection = null; this.unsubscribeConnection = null; const message = event.reason || `连接已断开(${event.code})`; diff --git a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts index 86d743c9..02a266de 100644 --- a/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts +++ b/frontend/tests/unit/features/assistant/application/AssistantConversationService.test.ts @@ -68,6 +68,9 @@ function createFakeConnection() { return { closeCalls, connection, + emitAudioFrame: (chunk: ArrayBuffer) => { + for (const handler of audioHandlers) handler(chunk); + }, emitClose: (event: { code: number; reason: string }) => { for (const handler of closeHandlers) handler(event); }, @@ -170,6 +173,34 @@ describe('AssistantConversationService', () => { expect(service.getState()).toEqual({ message: '连接已断开(1006)', phase: 'error' }); }); + it('unsubscribes the old Push-to-talk listeners after a mode-switch disconnect', async () => { + const fake = createFakeConnection(); + const deps = createDeps({ connection: fake.connection }); + const service = new AssistantConversationService({ accountId: 'acc_001' }, deps); + + await completeStreamStart(fake, service.startTurn()); + // AuthenticatedWebSocketClient 切到 continuous 时会关闭当前 push_to_talk 连接。 + fake.emitClose({ code: 1000, reason: '' }); + expect(fake.unsubscribeCalls).toEqual({ audio: 1, close: 1, message: 1 }); + + // 模拟新连接上的 TTS:旧服务绝不能再收到它,否则会与连续对话重叠播放。 + fake.emitMessage({ + audio_id: 'audio_002', + conversation_id: 'conv_002', + payload: { + format: 'pcm_s16le', + purpose: 'reply', + sample_rate_hz: 24000, + speech_text: '', + }, + type: 'voice.tts.start', + } as AssistantServerMessage); + fake.emitAudioFrame(new ArrayBuffer(4)); + + expect(deps.playback.startStream).not.toHaveBeenCalled(); + expect(deps.playback.pushChunk).not.toHaveBeenCalled(); + }); + it('endTurn does not hang waiting on a startTurn that never got voice.stream.started', async () => { const fake = createFakeConnection(); const deps = createDeps({ connection: fake.connection }); From 053d1266cb0068932287398a5d0a636f9e23d09b Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Fri, 14 Aug 2026 18:55:32 +0800 Subject: [PATCH 9/9] feat(reminder): notify on headless geofence events --- .../src/app/composition/createAppServices.ts | 4 +- .../infrastructure/location/geofenceTask.ts | 151 +++++++++++++++++- 2 files changed, 148 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 474283cd..5b0f0377 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -10,7 +10,6 @@ import { LocalReminderDelivery, LocalReminderDispositionSync, LocalReminderRecovery, - LocalSystemNotification, NoopPopup, SqliteLocalScheduleReader, SqliteReminderStateStore, @@ -22,6 +21,7 @@ import { ExpoAudioPlayback } from '../../infrastructure/audio'; // Key 配置好之后如果想切回去,把下面这行 import 和 reminderPorts.location 换回来就行。 import { ExpoLocationMonitor } from '../../infrastructure/location'; import { + ExpoSystemNotification, NativeAlarmScheduler, NativeDeviceCapability, ReactNativeAlertDialog, @@ -73,7 +73,7 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe delivery: new LocalReminderDelivery(), audio: new ExpoAudioPlayback(), device: new NativeDeviceCapability(), - systemNotification: new LocalSystemNotification(), + systemNotification: new ExpoSystemNotification(), popup: new NoopPopup(), vibration: new ReactNativeVibration(), recovery: new LocalReminderRecovery(), diff --git a/frontend/src/infrastructure/location/geofenceTask.ts b/frontend/src/infrastructure/location/geofenceTask.ts index 7f9369df..4d1f3239 100644 --- a/frontend/src/infrastructure/location/geofenceTask.ts +++ b/frontend/src/infrastructure/location/geofenceTask.ts @@ -1,4 +1,5 @@ import * as Location from 'expo-location'; +import type { SQLiteDatabase } from 'expo-sqlite'; import * as TaskManager from 'expo-task-manager'; export const GEOFENCE_TASK_NAME = 'timeflow-geofence'; @@ -17,9 +18,22 @@ type GeofenceTaskListener = (payload: GeofenceTaskPayload) => void; const listeners = new Set(); /** 没有订阅者时(App 进程已死,Expo 只把 JS 引擎拉起来跑这一个 headless task, - * AppRoot/ExpoLocationMonitor 那套 React 生命周期根本没启动)事件会先落这里,等 - * 真正的会话起来后由 drainPendingGeofenceEvents() 取走重放,而不是直接丢掉。 */ + * AppRoot/ExpoLocationMonitor 那套 React 生命周期根本没启动),通知失败的事件落这里, + * 等真正的会话起来后由 drainPendingGeofenceEvents() 取走重放,而不是直接丢掉。 */ const PENDING_EVENTS_KEY = 'timeflow-pending-geofence-events'; +const TIMEFLOW_DATABASE_NAME = 'timeflow.db'; + +type HeadlessScheduleRow = { + id: string; + title: string; + location_name: string | null; + schedule_type: string; + reminder_type: string | null; + reminder_disposition_state: string | null; + snoozed_until: string | null; + geofence_armed: number; + status: string; +}; /** 订阅系统围栏任务回调;须在应用入口尽早 import 本模块以完成 defineTask。 */ export function subscribeGeofenceTaskEvents(listener: GeofenceTaskListener): () => void { @@ -73,9 +87,17 @@ async function persistPendingEvent(payload: GeofenceTaskPayload): Promise await storage.setItem(PENDING_EVENTS_KEY, JSON.stringify(pending)); } -function emit(payload: GeofenceTaskPayload): void { +async function emit(payload: GeofenceTaskPayload): Promise { if (listeners.size === 0) { - void persistPendingEvent(payload); + let delivered = false; + try { + delivered = await deliverHeadlessGeofenceEvent(payload); + } catch { + delivered = false; + } + if (!delivered) { + await persistPendingEvent(payload); + } return; } for (const listener of listeners) { @@ -83,6 +105,125 @@ function emit(payload: GeofenceTaskPayload): void { } } +/** + * Deliver a notification while Expo has only started this headless task. + * + * AppRoot/LocalReminderApplication is not alive in this path, so the task must keep + * the same edge-triggered armed state in SQLite instead of notifying on every enter. + * Returning false leaves the event in the existing pending queue for replay when the + * normal application session starts. + */ +async function deliverHeadlessGeofenceEvent(payload: GeofenceTaskPayload): Promise { + const database = await openHeadlessDatabase(); + if (database == null) return false; + + if (payload.event === 'exit') { + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 1 + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 0`, + payload.schedule_id, + ); + return true; + } + + const schedule = await database.getFirstAsync( + `SELECT id, title, location_name, schedule_type, reminder_type, + reminder_disposition_state, snoozed_until, geofence_armed, status + FROM local_schedules + WHERE id = ?`, + payload.schedule_id, + ); + if ( + schedule == null || + schedule.status !== 'active' || + schedule.schedule_type !== 'location' || + schedule.geofence_armed !== 1 || + schedule.reminder_disposition_state === 'pending' || + schedule.reminder_disposition_state === 'confirmed' || + (schedule.reminder_disposition_state === 'snoozed' && + schedule.snoozed_until != null && + Date.parse(schedule.snoozed_until) > Date.parse(payload.observed_at)) + ) { + return true; + } + + const notifications = await loadNotifications(); + if (notifications == null) return false; + await ensureAndroidChannel(notifications); + notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), + }); + + const notificationId = `reminder-${schedule.id}`; + await notifications.scheduleNotificationAsync({ + identifier: notificationId, + content: { + title: schedule.title || '日程提醒', + body: + schedule.reminder_type === 'return_to_recorded_location' + ? `您已回到${schedule.location_name ?? '记录地点'}附近,请及时处理。` + : `您已进入${schedule.location_name ?? '目标地点'}附近,请及时处理。`, + sound: 'default', + data: { schedule_id: schedule.id, reason: schedule.reminder_type ?? 'arrive_location' }, + }, + trigger: null, + }); + + await database.runAsync( + `UPDATE local_schedules + SET geofence_armed = 0, + reminder_disposition_state = 'pending', + next_trigger_at = NULL, + disposition_updated_at = ?, + sync_status = 'pending' + WHERE id = ? + AND status = 'active' + AND schedule_type = 'location' + AND geofence_armed = 1 + AND (reminder_disposition_state IS NULL OR reminder_disposition_state = 'snoozed')`, + payload.observed_at, + schedule.id, + ); + return true; +} + +async function openHeadlessDatabase(): Promise { + try { + const { openDatabaseAsync } = await import('expo-sqlite'); + return await openDatabaseAsync(TIMEFLOW_DATABASE_NAME); + } catch { + return null; + } +} + +async function loadNotifications(): Promise { + try { + return await import('expo-notifications'); + } catch { + return null; + } +} + +async function ensureAndroidChannel( + notifications: typeof import('expo-notifications'), +): Promise { + await notifications.setNotificationChannelAsync('timeflow-reminders', { + name: '日程提醒', + importance: notifications.AndroidImportance.DEFAULT, + vibrationPattern: [0, 180], + lightColor: '#D7F36A', + }); +} + if (!TaskManager.isTaskDefined(GEOFENCE_TASK_NAME)) { TaskManager.defineTask(GEOFENCE_TASK_NAME, async ({ data, error }) => { if (error) return; @@ -105,7 +246,7 @@ if (!TaskManager.isTaskDefined(GEOFENCE_TASK_NAME)) { : null; if (event == null) return; - emit({ + await emit({ schedule_id: region.identifier, event, latitude: region.latitude,