From 05de2c61b8ac1952ae86dfb5e1afdb6294b2c0d9 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 08:08:25 +0800 Subject: [PATCH 1/2] feat(reminder): add native alarm and Baidu location modules Native Android modules for the reminder engine's device layer: timeflow-alarm (AlarmManager scheduling, ring UI, boot rescheduling) and timeflow-baidu-location (continuous positioning bridge). Includes the Expo config plugins that wire them into the native build, app.config.js (replaces static app.json so the Baidu API key can come from an env var), and the new package deps/patches these modules need. No app code references these yet -- pure addition, nothing wired in. --- .github/workflows/ci.yml | 4 + .gitignore | 1 + 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/modules/timeflow-alarm/.gitignore | 3 + frontend/modules/timeflow-alarm/README.md | 5 + .../timeflow-alarm/android/build.gradle | 38 ++ .../android/src/main/AndroidManifest.xml | 39 ++ .../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 | 376 +++++++++++++++++ .../com/timeflow/alarm/AlarmSoundService.java | 382 ++++++++++++++++++ .../java/com/timeflow/alarm/BootReceiver.java | 21 + .../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/tsconfig.json | 3 +- 43 files changed, 2752 insertions(+), 71 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/BootReceiver.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 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 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/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 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 + + + + + + + + + + + + + + + + + + + + + + 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 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 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..1469561f --- /dev/null +++ b/frontend/modules/timeflow-alarm/android/src/main/java/com/timeflow/alarm/AlarmScheduler.java @@ -0,0 +1,376 @@ +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 + ); + + rearm(context, alarmManager, record); + + 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, ""); + } + + /** + * 系统重启 / 应用更新后重新挂上所有还没过期的持久化闹钟。 + * AlarmManager 的注册在这两种情况下都会被系统清空,但 SharedPreferences 里的记录还在; + * 不重新挂,用户在下次自己打开 App 之前不会再收到任何提醒。 + *

+ * 已经过期的记录直接从持久化列表里丢弃,不在这里补响——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, + 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 + ); + } + + 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/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); + } +} 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/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 d2673b87f4521c2b8b353a942c8667df71fdbcfd Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 17 Aug 2026 08:08:42 +0800 Subject: [PATCH 2/2] fix(voice): location handshake, TTS interruption, stale listeners Three unrelated bugs in the already-merged voice/location-search path (PR #243), fixed together since they're small and independent of the reminder engine work in this stack: - reuse a recent position during voice handshake instead of blocking on a fresh fix (backend location tools + realtime agent) - preserve newly-started TTS after an interruption instead of letting the old stream clobber it - remove stale push-to-talk listeners left on the shared WebSocket connection after a session ends --- .../gateway/websocket/handlers/session.py | 8 ++ .../external/location/tencent_maps.py | 22 ++- .../timeflow/intelligence/location/tools.py | 9 +- .../timeflow/intelligence/realtime/agent.py | 28 +++- .../intelligence/realtime/instructions.py | 2 +- .../intelligence/realtime/schedule_tools.py | 13 +- backend/src/timeflow/main.py | 10 ++ .../realtime/test_realtime_agent.py | 7 +- .../AssistantContinuousConversationService.ts | 61 +++++++- .../AssistantConversationService.ts | 15 +- ...stantContinuousConversationService.test.ts | 131 ++++++++++++++++++ .../AssistantConversationService.test.ts | 31 +++++ 12 files changed, 314 insertions(+), 23 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..24abe511 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 ) @@ -327,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 @@ -373,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) @@ -389,6 +404,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 @@ -476,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/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/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/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 18e84ab6..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(); } /** 用户点圆圈暂停/恢复。暂停期间空闲计时器照常跑——忘记恢复也会兜底挂断。 */ @@ -286,13 +290,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 已经绑定在这个 @@ -362,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(() => @@ -372,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' }); // 播报完成后给一个全新的窗口,对应"播报完成后进入短暂等待"——不用单独 @@ -380,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': @@ -422,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/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index c58bfbe7..da30af94 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 的连接。 @@ -288,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/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 }); 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 });