From 7b8a8cbb32a3618120cbe01ffe4a3630fde36874 Mon Sep 17 00:00:00 2001 From: Ashin Walpola Date: Thu, 2 Jul 2026 15:04:34 +0200 Subject: [PATCH] feat: CAM-based use case detection engine, decouple DENM from sensor triggers Added: - UseCaseDetectionEngine: IMA-B, IMA-S, RTW-B, LTW-B, SMVA/BCW-B via per-station CAM history, yaw-rate/heading-trend turn detection, Info/Awareness/Warning alerts - Ego state from obu_gnss (~4Hz, yaw rate) with phone GPS fallback when stale - Human-readable alert narratives + technical detail in V2X Monitor - Settings > Use Case Alerts per-use-case toggles Fixed: - DENM no longer auto-fires on braking/stopping; manual-only (antenna/RSU test) - Removed unwired CamBuilder.kt (dead code, contradicted CAM-only architecture) - TripRecordingService no longer depends on MQTT/OBU connectivity --- V2X_MicrOBU_Android_App_Requirements_v9.docx | Bin 47520 -> 0 bytes app/src/main/AndroidManifest.xml | 6 + .../com/hawhamburg/micr0bu/MainActivity.kt | 26 +++ .../hawhamburg/micr0bu/data/CsvExporter.kt | 58 ++++-- .../micr0bu/data/mqtt/CamBuilder.kt | 57 ------ .../micr0bu/data/mqtt/MqttRepository.kt | 149 +++++++++++--- .../micr0bu/domain/detection/EventDetector.kt | 12 +- .../micr0bu/service/TripRecordingService.kt | 54 +++++- .../micr0bu/ui/navigation/AppNavigation.kt | 1 + .../micr0bu/ui/screens/DashboardScreen.kt | 6 +- .../ui/screens/MqttTopicViewerScreen.kt | 182 +++++++++++++++++- .../micr0bu/ui/screens/RecordingScreen.kt | 25 +++ .../micr0bu/ui/screens/SessionLogScreen.kt | 9 +- .../micr0bu/ui/screens/SettingsScreen.kt | 64 +++++- .../micr0bu/viewmodel/MqttViewModel.kt | 111 +++++------ .../viewmodel/TripRecordingViewModel.kt | 28 +++ app/src/main/res/values-de/strings.xml | 28 ++- app/src/main/res/values/strings.xml | 28 ++- gradle/libs.versions.toml | 2 +- 19 files changed, 652 insertions(+), 194 deletions(-) delete mode 100644 V2X_MicrOBU_Android_App_Requirements_v9.docx delete mode 100644 app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt diff --git a/V2X_MicrOBU_Android_App_Requirements_v9.docx b/V2X_MicrOBU_Android_App_Requirements_v9.docx deleted file mode 100644 index 1babb2c992708b2d21a3e21c4e1652edafd4c1b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47520 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

six&5=6{(&TN!) z9orrL8*_j^cmR;kJti}He{h4*92aQq^8Jh#wqw!Ap*%R|M(zRn9w;CR*?IOw(w}O_ zYN6Ejw$qts3#)WrsJpd0g|*>O{J{{P5LzJ7OnZg!6Jns#XMlc?cQQ|0$x+YSL|A6I zn9Z1*wPYFb74P98#4c&XrIf_IxE&k`jwa|id*qk|b%%lyD|CgI@)!L8G}Z#T&9|(O zG`|%ykN#QKpcEHhI!5_NH&JB=d|}Bc|M#;KwWLI3G?1t2bv;tly#+Hvz42g#Tki*W zTTwQt)QNNRjfkee5J5I@=`VXyjbW90-mUBBOU|Ro?1j7h&_4WqO`4@K@vJG#RjSv5oYk}NgSw)bfL*Q(z+&O=IWG2cJwojLzM_uaxCqJ`>PtC zmZxs$^bSVd%jP3K)LW#YTgHP6>;+-ggruC!rGh3|-}QE%RZAjphQrR9QVbua6fRMNx#Pq4qeT{ME=7#){79Zen@<=JV@SD%5>9*WVh zq627<<6Pzi2XG9gVPB&)&D3 z**BpF#sU1s_lY%*C&1+AC70{8=7cMH5<&u(uT=b&y(lzY&pXaN-Z6SSfoMctd({M+ zpwaw8>?ri(d7}3#HZcLe` ze~8M3cZAH@V)~;F{k}`CMr(C9(gM2-QLzZH(B6Yu&RTO-e)4q;wod5fs0VHxthh}T zbmU`SPxiN${{y!R{YZ@s zzrn{n{C|KCGh={4ab(+oj1TX zE_gpC-ohI8n zcsIZ=2@|Ys{Bxr-o0ft_Y#7NZgGv6HX{roKyLgxh6+P&lr4aliNXl^!-0c}JDPPQL zi(a^lc!6tsUxrPqUZmL&T-Yg@1$NT)hzcc4eEsW-E9xQYk3mV`{p*v%x{ z??envsD8IDM1(*E`I&$-jbTWega)4^n#g2R74=5ovog7*csS`@zm!ycQ--!Q!{E`# z-YT>5bhktDUGxvFX%J;+FzR@3O#UBpva`#cx7lsuQ-Y2yPQ77=noCQ8VZ}EUi;l8B zTM%c)F9hyvwdAjCjAz_@bHpZLbYz_~$7I`)?SfsMYI6?u<7H1A0!u)mF)lo`Xhu#3 z242;c10&q=F}wWq1X5U8K0_>DBM&4Z91iYty+Lf6tW*+S*dv3$DjG%-QZ6qUZ5KQBt3NznWi^Pz<# ziKD&jUWW`JipGH+Vj{gil&~>~yb6#rzlsWB3g1W&ZqiDarPTfThg{zPL$YujcWG9y z8D>=RxS!fo-;DWrCH}e&ZSW_CiD9ce89c+mmhg^idu7RGQkFJdz5+)nxy^FArGw#? za*pIdCsoKU^<%=B$2Kr<^x#!@Bg?<kPHMG-$2dVU^n zN6WE$dXpYt7tD)+@1}eT9E+ictfNV+;pSLjK7;bs`ZT8*JjxbqA-BiWJ6&3!hSo}n;_;khYoDPG-*_J63dS%04g9RA{xs^ksp36&)}VH$$_@|~~S zho<>+um6K|kj7Bk@F49eO_Cny;$7P!4k%YXno?OxGIl>4{~pN>>UoDB0Sujv839yZ zo)>ZyQFW{*hk#q?fStn;S8SZ*H-;g!HgKX)*-evSU;-hwEqJq#JPWXqGe(fm{4XXd zFx3Ts#%xfga_vdV}{_B#rnoN-Nr{%cg;2SmPH`+ zy=2z$4M5L@pxG(a=$oHbT}P#bsV8F={JVY6am-1`GtS^#DJEnN;G8~(zRa}m%?&}s zQWv^aR=Od1z|nDA1JP(rx6@(z3`FT@Gn$cc*WNNEf$1=pXxRtp*$(}2lf!`dfCQqh z`}#W=Yr%l>Y8%NyqRB~n1GFJVyi+`ky!@)Ng2L1#{hO6;F-1&S2XnS`qCKmEunwiS zPXqlUqT8Wocy-@uDp1M^E8(=71KKj958i!l68xZaqvXPR!MbR(SV3pA3J`c_qz-&d zPffvrnj58*%!!GVde+1cDfZ-5^3y>Dtf2$+xH~Ps)&6v)JJ(6d%C-vgz9Rn9QTQr@ zk=q9(S=x3#8B1FtqTWd!xx>}uFl}O+M6%U?F}GPGI*C|uVK)0-S@!Ze;eGK=@a&v& zp0n$076V^P<7hK9Th!So6%gVfAC*>7S{*f}<&S8ZUR%#)pz|Xf-r5Y_pJLrI{=#R$ zTWOH|?Kpd(|NkKCExH#m`R@xH#BX%{H|6t>0lOF*C>c9CenabjY&dI9DyEMB0bsjZ zu$6j2#SjZWWzw2$MXf%D8>mTG538r@^DStxomIz34ki7CiGexdL4f+6xH3U6*_CC2 z0VxO;aNhe}5>CyNK%Ju5X|OybvNxI@>w~^U zi?CA1ni?=zd%li!Vf?4~kOo^n`Os!Qzk)c~rQbypLsuBNYAJ!gT~VcW$wG@6tGbSc zUSFFnewC)2lKIqxnLViaC=Cb2JA?LNqJmJ+wZfvO`2)E0obL@PgW+zh$%GsjWSp1S zEsLqMGszX_msBzMwHFd&*V%2T6wH-Iw1-Zy-OniT$fE4z`J@zsyx9Q>IZJG5z_kaN zxJR=&X4m<;;PeAIE#PEp^pkZNQ9bBw&U0tI1CCI^P# ztZC}2KoI@_(H5TmBwU7*T_B^xYdCF06SLwq1z~-UvE7klmmYg8f)0SnK+lXJb(csG z?qQrHxxGrT=rOU(X)6}p{+Opri(d+h}m@E7u~v+#QU@)t_|WK$Sy|N;AeHvW`_mc*-3(B-{@23 zvBzQk@qX`ejiI){8HFbyi``+&5^L3gjt77FlT8XM*uyiyAZt*guTywks+6MgYn3V^ z7}w-*(1oI{B9#K8Mc0~Yy-r|^XS;B(Qk^7w3mmw2dY&g%YEXY#a~c1js8kT%A6RNU z2pz#Z+)kG``yvWKHztX-sVCdY+IeITGfExFq-9JZjXp|1x`}(cBDs|Z1>cttD0)fz z91NIF&oVkYbII7aJVa+($+6n>96Izds=@Brko#|AnPfz;@r4BdXb%Da_{M<$a#sgO zH_LxTn}aM3hcyl)-!CXBxej>Dja!Aiw0cyJ3oP!N$jnJ*VL>!DlDq@`;kPq%5O{%K z{zd_z)LL&w5k@{nIsA9;4^LNqSHJ&InDG3@i1fjP#ldP{H{7`!+#a8s44Lb_>i&3j z$0N7-5rtdlgpmV&IjEOG(s8%L;M~GOa6|7YCRAtNU*Jo!YkJQEBYsc2;brSqOAM*S zoI`5UKWf&9fs$GZ9c05cQH0-?0mdR-_|A~uMR3coS^raeR;S~Sj@$kP(d8@$8Wx!M z6f^`h^@J-JKcE9K-kr>CgnTgvQ5e$hUfetV^^-3r90QaE7Ebm33_}7bg6IQ^r^! zN5LfZwpQ#5gYEQHwh&%i6TLVQ6)vKBGzfQ_X#<}u9*wLyTFLxSoG89g*_quF@kZ>t z-0r#QJq9XYgzoJ$EPTSU8!vg?`10U3lY#K_)k9`HM_VMO0-6}2OP@q=oZx*a11>}`ri zY!Btym3H`7M_?3m#=@u#KT$uBB*`tEPe%j|;OK z9d0hS8r-b!t|{;PX!YYTEZ*uZ*xY72mX**$vv}CanSG@60tr+Wu>?r7XtF;uI5Ic| zoaqcAwn&$!`SOsDWn`-vZ5M`tm@Qu7413Kn04D2?{x(BaHCNvc_SWAIo8J!xQAdYL zv`4aq8maeF^{_`0Bgm&D_Z~i2kf2h|9nP*RxnjiBlA1mHNmUr39&_46J}qJ+zqeeo zjbzgL*{6beOAwnGy_H6TuXTM=6rObHiH`*|bY)~EtF)XJ`uq$=f{ z1*Pj>pTLmjx>;jWKePM>?rmUDa#olW1R9LW{7pvnKquojZl4>CTfQ6Ce;ZJmJ{`Pu z10}-^(RNP`E>bzeY*3rwydlEU0I%@ugPcl7al-qYL>*NLS&Y_M=6O#JgPrV0qeH2x zah8*3#-TJpi!{=2*!R9B)NNGRMfc^d$w-yE4{~+jj51SXW-ZRIX)5iqzY>}~)M|;m z)8)+GOtB?i#g=~nTC1aINd>r920?r#%9z^F$~-)Zt$g)7R(_g0X(v_U>G(C;Caw%h za~nTDT8Rz0|uv`P|X52vT!CP*A5cEdSqAvItSw8K9fWWi1WuRN$8 z&ydqJ?zNo31L}7f+JM=;-P>OA-27ZLF$t~%{=F;F2W&C1E4{tdHT$r2fwNs?=HJgQ zwW_1Z#Y>_uWuhNDDzV-mj5H^W#zbvcy8j`twU{ZKjk{_neCNT7^ee2DsCTL*Nh_bO zEBw_^pN+)riv)3e3VKDdoSd5Bpiw`w`b%Wvv*;4pr7{!qHIwZ2y&&`Sv3u3S_l!@5 zqvs)qNZclThcH4HdJJ#-mD;+-{;VMLx={d7jp-`Qc*g9rgG5RISYE8nYf` zqe(B4*<|3lTmVCJS>3*dvihy%nFXT}$#fqj=jfZ*0FT8~EQ40|H(7tvk64BZxMQD? zXrMEnjK8c#7OsH>R9>%ncjENg=7cH+r~%a!$%J}}WI{DlBDIPm!`t!LgqW=NF&G2X zoca)@u+xuB+N^dDF>I5Du*U0EnkJKA(mV!n%SbNmnxQ*vZOD=BCpjDW8s}7ULicP_ zlhC+2Tg-SLro!%9rWRLAGrn!%{!XWbm4TcV*1b&uzSr(7?GrDSx#O?%6gBQ$CfC!( zx50Ri$KNuU3(Z_>H3nz}s-yM#d`dO3c31D^Lr*$k8@F+i|>r$Q3vjKKAIH=?XPj~;qe>hmrAlO%P!?zWCV~GN@%I%$5Gn<~R)u^)C@zJ+ zD-Q6ImD(5rI6D@_psbwYR#+0|o<(xSCZWR8M{z2g~z@c1}(*aTT|3@yFgRO4DI|`xvi)?(_R^OYgptehZ%Vaz!3!!kRRo=gJBajuIvd zC^r=(7Pp3CaW&H3N{2;dFBJ&}n5p#e>WT5&ViZOkz@1{I0*DyaU6}<<#1Jy;@|kdg zMq4FgRggTtjMF+I4ww2}Pa57Uvl0;}A4&q2paMaT8RYanq*qA~>+XiR`@rhWIpp?s z1v&l~rHEc`)fhn!^%y1xR%zzSib-S@xt?|`q)2U8hQsP$3PUxa#Iy>1{ekbk+G;lX zDR=+43c#WD1q9>sU!mWFaK>t@gFz}Q2m#Mdvw`c;3@^XComqv`+OT9V|M#n3;tckz zJ*gd7`H??i6-=tZelo2t#QHk1lIZ<}g%qs@!*KXssIG#re01TKCfu#1Fm#VhKb*jt zUlqh|Py>`;73cC%(g8yOhn+vVhWmErRhl4BMBig@5u>LPQ`5ASV ze5(C#w&B3fc9x9wxBDMQ+4sH^QyUCEJGas?*qRISJPor%!h(Dld-Hbv>??dj?+%QHsz5E=T{E|6dvM1b z?+(e~>&VoZO5wErdTB79Jes~W9#fl;K4+O(HJ>#o>3=@9ZXZ4IJ0a7Yi>fQm(9W5t zK^rd4pvgVCHRhFJUr4upEs$nclyNC_A;@AM`+A?N`0|ad+yc z{la!<$ZV$LUR^iZnjv#PoMn_y5^R{9P%(7V>^hu3WuDcUT=}sMzMIqW2ibRNcP`;2 z&V6e%cimRP>&tIn>n_V%DaShA^TO3pv%x#7dsKxQf0VKtje&s&Czx+`@(`J&x5w5^@4l3i0;fN79#QXO|LJcN^{fn-3TrHA^0AlwI?}ro z+Ws=_Hf@*0Q*l$xVD)J zF>Tuz-mHc3U`GTsC~cKI&&++4zMYv>H{kTAJrt;}S)%sqIcMd)M9_=@yFo|cw7eP> z>6TN$f|$27WsPc|%)u`eXG&=k84Y6{?((#yXqey2vA+CKhRliB`}7H07Ud0t)0QEU zXJ_x+8n1f6Ift(+hHdg?Y>Dtcpuv8Zh=0O^-`1fGvr#Ov2i5)R%Nd7wP2&nmN5glR z5ECbgK5{^{IEBPexyzg%d~-@4S}{F}m@#$a!l$i)Qi&+*NbMUM+_LXDf7iX7#f@oj zXfUp*utZLB5gw75Dvp>{=d7`WeGQ4u=f%i6pd!_YhS6MOG@Dp6(age?5p7DZrT8%w z1{slg|AmDX{^%57@KxKYz%xruGxoK+_4ywaiGNEcC~!9M$Up%Ay1on5{`Ydw;Xf`` zn$)clGT0D4sU!K880nEP$WkKREaKZNxE_pOHNaLDaR@|5jyTdjpQ-_}>|{qX=MEDF ze{@XKRV@6vIcv!|8*?wrH~ig}##wWoCX(TwQn|7*@nhrY?%@0)V`;dwK+(Ulfxr7C zzr8i>qR6sfFrWIdk!*u{a~7jk<9^Z)CFz_V ze%z=whccFM57P)6D|4kE9?!u?isj~XFd?7bI#nSZK+;%>{bWDyzqoZ2lga}0=xd78 zrh)^83o&u+T1P;rPdcfhn|M@MLX4_c(A$5F!H38)>7Cp7eIfM50OdyAz9Wvke9q5i zBDwimy@^b{j*U<+ZZZfW!Ryf*VVN^gh3PkB&z=4AI?x`ZLol zfY~1A0zbS@c&K>$x>2?vRQ(l_r7b;%|UnN2nN-jd*FmFMWq%;v{WtJ zT$U@FRDhLMK>RUJ_6m;g9A&2&QY5Yuc z*EFG4&%1sH6x;*e>U*y^+VZ>nIWGBX>X~^aHc%Ee<%EG)(CtQm0WK=*0b^#GA#*l< zG;N`&vJ%o$oU?fizd-@JSj9UQy&Xzv?1D7lRr!zFR*;?U?$$uQ$&$YSXSLkJAy51# z8;DLFT=f}RvD)Y$>0xf^{-6hIc!9v>&ie4U?J+EzxBm@*Pn2D&uuk%X8i&J?b@2NO zl{1cdm1kgV3ccO!v@61c52DQ{=)Z5k{%)!RfNw^BGcGOP8JGVp1^HWl^&cshwfOvh zDzJ8SfbrxhoqkzVuB5MJZGevi5>Srj#c%Iiumc86Y-{?Sftu}p9mth*=zrK3x48@` zB2KY+bHI7RGY=s5Jz{@NU$xB4`m9vT*^RZCeCJ#+0hMkfa-(w0X$=5S#u$QrNkhhK z-zdi*miU%X3SeZmO81aJ1l(rC;^-H_e4y47D}SPfOl_Ej1(Fjt_h9m4CSv8-MAot! z!Kv!4fXRUQPQa1tW3ygFh5I@!08N7VMOL7h|A=%nb(mq8EtJuOA(mMyJYZ0y&NAcv zC+AYEtynrK*9MLYU^`tRTQRXFTsa&=y8)N=2N13Fk;L zDQyC0?}_3|Jo(Yy5QO_r%0&&gA9i{0^Woe~hto$ICIGJFJNcO(5maNrMLutIZM7qi zXAZ)C{+;~Pn*|bvaPd^<7`hz;I9C16xo~~wT!dLeNMNTJm|^)9?**lcr$Rb4+yaV_ zvWz}#FC%>y1Ywy=>uUxY4T)_BGy>E~)u(3iMh8Cp*=qdISHDZIe5&PrBy~cuqj7wpDiomRIJLtlzX zN`IQ(UmAk<$FCT?WGxSI<_Hq0#%4~YyC~AZvRul=VOCSn$(rm){+n{i_Z;)|Y{6f* z!@u-j;L&{7eRBE9vnsI{vig55SOLMB+|Bj7UKSGc->~4jaMsD{`$si@bB5=gllFTf z$z;z|)@{ed#p2;UuTkwSQAA}B3$P-kf{1Z#QVu|}NR$XYPT+-l$xdq<6U+0>C2Yov z4b&MA>Q$yH9{?Y4FC-3AcHj_-B*5=+{^8fZRS3gCaNwRC-rs$WuD-@o>ZUJ)lyxxP zPfFaI9+8p#klS?_-sj|Ue(0#m`n=Nne7=P^usg=rK^TB7fuFt;+JD~9QA8Kq^$Q(D z4y0C`E;|^N_6cY^_1)-mV7NwectR9;ESD}=+-s2tiBW(CxVYL*FUXm297)y9XpiD= zo8Z(s9Y?lC4yK%7JT!rGa`ABUc=5;^2Ka5mVm}NEu`c1AJ|mbunB^{t0{2$pML9OE zh(qgKx;DgN#fWjOSQZ}c#mTZ`pCEEZ;Ul|ZgaLHn1lms+J}be&4{L9dDu9ATyPiMr zZbgtYm|Sq+27B~wfHC`b)FTebxLhF0!VTw9zZYjjBYmEf2!kCReo8v?$$n^W*?1ed zKy)t~?%0`z>`I+iaCEAnX>faaEaA{J{t8iiu8r2u71c@1g4>f33!oP}A$Y1l+x9;H zO3bo+@ULt>H#dJmOMLcT)>If4uazRP!J7=Cti}dp^f!{PUw~x@G zcYQ7dX2FBCJrDKoBf{OAkF-0MZo?kFfvQ*GnzCbyJ~|v)v1DokTsNvB#F})(dj__d zjw)JT#;s!4ZWjx2rH^?Dt(Z9v4e$pS;4+|Q&nIKxbG*DY41Ykopuo0en!3y`N7JXI4cK;gC(f(iJ>w9PNd-NORC z4PV?AUUc6-Yh@~&1rsxnBM*5VatY!idM$Fsvm37q2+tZr7ZW2p&A-1>LLg}Pen0RI zo|7HmeB%8)C)cF;)M{t%oaQx;3Y`6HcZrSlA-W5XY6rdvQhgAh?kjBG_=^ws^W_SD z67|{P#7MR|J@V7$z)04d4*sZ{^Lm9B9L|pa93m};5JvjzcWLkI%>Yy>C<3(;(|B<2 zlnHv7x99A|e7Y$%y`tmJDQA!Z*H0r&-R+ChC%z9y>FypypryrY7tO$xrK=D-oM`sW zFI%pNljH|boF!YSP675b+<|Uqv9px>t#>#!S^TTqPnzxkc7hC$iUkeudn?@m|Bss% zAN|9*p|-;STOL`UX+qZeXS9~?nG@6}ue<)=Mq6K))<8iQ7-3-EQRIl0VQ)casQ%D& z+oj+UUZkK+$?^blyYle+5dyzYIxEbs&RnEyI98u$bO0>qH!WS>jwY@7JR9G=x`{o% z(#O}s`!*NQFSK7*Bf9xA@nJvRv4dSsq%DGRz_OKZLauu!{v5q@ACJo(lkX8ceN2E0 zJRkalTR$!x-Pc&cVG?#FXGe6y?T|<2mB2Y#MhB$DWDGa0Bb;Q_X^-&5tkiS_ewZPM zeOVkHyk};N0Danq*NSE#g(y*}y9Vo?OP;LBCN7|b)~^yP zko9i`BGV(thbV>Xia!mQCttcqJ*B|DZ$THmP0?($w3j^WY1SykUV2m&fKj|-ZVdZ$ zfk`1NmmM^Q@$GXRd32aeKXvBbp?h^b>HK;5{3Jw}&AxmSczB8Ov{UwZK~e6edy)99pc4U3_Ix9vCL9EpMg5g( z4fGCr;P5o6FAel!?x&1s4l&c(PAI%cyZ88^e2En&9ntfeRcn2h8QfvUy1#JC%>eod zTNfhEoX?qiz#N6$jI9Ipuq<@nq{Geb^K{LJJS83ZIp2-leRvZtCRl=9-1cvcNMpAB z;)oj#9PiASVEpM=$ww@!Ez>$%Q4F`Ct$lSyyw=E!C;c!ax;r4W7@om;%U^V$4ud_= z+v8o={or6RMv5?jD1OA{&?)+E6VH`ymWD%YCYe)?(}qLCro&POLou!IX2!l4Ia_bH zu6E2CAFK-R3q`z{Uf~m)=gYtS6 zB_XA%w3N=uYp>WYi)ArH1?}%S>mGrZ7yabJfA;fvQj}Lt)~Xv+ei?aT+Ajerg*=c) z2E0W36r|CiQg1}Wfn(>6O{8&jl^8tJB2~-R-Mi+(r6Pkdf@>hd^yZ^y8!42EUni!8 z2ilHX7H&`&+XJWGV9=>2dEr2!N!UbM$2fBB>qMhG2-t8*|y`@(HUoZO?i zfrdl27|RM!uXjD|?TQR>!Y))p0bk0tI(b}GIyUUL^S04Jt65TSv;&P-lRR~92`JzD zd%&MN-xE3HbV3{2=!It=f@Zgh$=yX}0`36qGU^eEW2d2e?)XMMUun08OIEb*xza6; zC7tAl$Zz)67|`s?gG>d=4DJVPv!IQLr0nMcF@j5Rp(u{CM%&NyN~A;PsVhH2O9z|E zUgtrC#EUV|ybiq_=3iP4(y1#v@#f&tYcT7m2DfS{xNLNtXlR(8IC;4LRJCuassx5I z?xe4S(2j+ag5jtg!Z~?}f*i`95kA?r!06?IC#RTj?ct$OQPmUoljk!10WWM)k+*)k z?iy&Kg8}l2RYc3iKr*a@)4w;&10iBh4;tE-Q3$`dEgr#w5{3!`+s6!sL?e;LVMhc? z?bZ5G2*;N4^9}X9zJC0)Q)J42GGD`@WMrd}Pjkl@(it2?i$%SOEzG?iU_W-Kh+pG& z2D>2fHJ#s~H8DXslQS%_EDgmE_JETjqUT*`8~<<)OZXW?gi^?#o4EKsASBdcz?Fyo zqZ}f7hMf7VH$p6Ooc3KWJY7IUE(q1hhO0%d{vRT^*5eDQ~)Wlp4>^6obv;DFR}kp&mhG}sTX3T+>x z)K$~S5AB^zeWUSba3c?8{yL%@Cq&mZW5t{+yPyPG`~;wq7&8H5>X<)VDCt3{vBdl% zNj}A23l+@=P|u(dYYD|$Fd8t5o}(t}m?SCSy9eW7{^p@rDG&Cyy0P_+BT<7 zqG__0sggr2kQ6}*P2cQ{O~4TDmsisw-RlZuPDW;eCY9!Ky#`DU;d}`u)Y}?>Hv_ma zr;yld1wlqO0D6O#E_S$6L+hdPviAHz!8Zo-zTV1}N{QPaV-4pRtq%a-pXmO6z9E2W zM0rMvBupF`04vMTh96*rSCSZ85z~^iBqlv*OP@k#;)MfDl0PlF_W`c38rYjsO=_ct&2VT=;xm`wAK#7>cy) zi4xJ63tse|Z`c8?Xq`NQtg5nXF(*wcTJ)cPRnBXA5;!0d4%sQ`iGcOh4{z}chFey- z44~WUyLW*yRwxM&f8wN@ZUxzKh(>~Xq&Im6Tqk{Jn4J_uDj1ix@SCq#l*6&3GbsSNx^`k*_9ak&ZEuA$^>&`!%ZYQb%9jbYsJcme|zMa*mHm(C5JL+h39P~n)|rhvxB)JI;#n! z!Zo%wo&!y#p(_|4x>Mb9g0BC;+wY3Kfsto0Q5XvIgI?@W1u1T=bF^52Sc!4h`K^cA z^1y>wzjS47HI+}gbTRr+CTLXiQ5%z`{@lqvKYN_4bZ9^$9iNXTK7}H|NG+;#l7yNS zlLItgaIHYrTX+9@*gT=r?e$gd713?Gbtj^3g5Gx)Jlf0hf|qR1f^0qR|Nppp$M(#c zHf*zF+qP{R9UE6{yTgt;w%xI9+cvM*wvC?L&%86UZSw=xm$j-^RUP|&p5;tkdIJZM zqKzNjFI;Fhe6TXHyJsOVc>}pHV(VOHPG%}}#&R3bv;$;IPSexjD)`^`K0TZ0J-ZdI z)Pt)=mBtv;s-0ZnNAw>rDBX)8>y?8Yp;mU`x8aV@`_(Tc_7U?)eHyGScXoG1vgBig z8@4}0+2KoD@;%mjXdx#9Vt!`bb0eObi`JB&vLUNlqHo17inr1t=@gts203Y1hljAD z>{bQ*Xi)M~>onvueNTxl{2mR%vcRC6vegwK-|rn;CdG2q*yEUIG)`LiA35;=m5T!5 zgJ^(4@u4~OW$EE~;B2&}i?yT#h0mD?<*%hlY+N{_$6dm0gM`qWmT8p&dRMi4FH}`A zC1YGoY9#DmC>j}?Fk>>yd2cp*eeDgBO&V3pNNIAQ3B7rQ8Te_`rpb5A;?P8@OS+#A zjcsj74Sg*{k1HNy<+u0X+l*UJm2nOK_t3ey>{k@W&$qa-d+GHw1R_vTFXi8ejLK*O z@f*BiR*6}jr2lTAO4e~b&32`M1l0PHvLq5#7o?>!!rc7!vwCg6*QyGypOuGy^3iD5 z6NgTi(**VjP8&0Zl4iqng>#2vFF6jtf4v<4F+phlO?eQH(D_+vF$AX(7Mo3V1^ec1fb7vms_X()@w#nc=V6-@3QE+ zPy}!qm>UNO`yEZ3H@aHdFRl`ZrO2YmZG>th^20292bmi};|&T03}nNoiYtnErKAEZ z%kS@$k|soy=r+fprNNCTgr<`Bw8r2RPyS!d8;}RLJMXmm!Sav4#Tk08@1j zQ8`eMQ-d5sst^T>ROP?4F|C zQVcACoy6%xn&bjm%`KHb@@uN=g(qo(4n>K7-VU0U;wbLf>vD%8f-SKc4`<;8_Zxwzb zhbPrftdUcjIpNS%u~Q%BsjQReiA4PpWuZ-vq2l#rEa8lstv5B!jNkkXsYiPILWwpeF#&B{8oUT; zVXhIAs@X8VLq&=dp@oBj{4Q#-ZAyZmA)#srjjs61X(i0ZmU6m{N&V_>vixR-xyek$ zmHv^=%hQ;wjfj$5nu9DgnBw{p7rr#^j)=e#WyV6@qva)v*trlRoLz zCr&7I81q?muOg5&+c^72npe55@pW-$|CPIZImq8y4M4$zvntc5YKW!LB>@r9gAnnk zQS44KkU7ATZ9DlCaQlggREft6Q-tc)85^1#f{(8EME`!XF)FJnqMB z(WoeC1DyW+&uP)QLwT$)K2|l@7wY&)gb~NwQuRmcpfs~wf*~yLnEo0=In$7JM`R0_ zF93jxy(}4p8jIhIlSyOoWMRQ7Ga?!-c#a!^0ftih#QKV~5p}I+$ku2O9f$J>s{k*X z1M57*rzh;!igvz1eE9>$(#`pEbpnw=k5{nv%VUNBD7#m~=4B37$LG!5-Ar0L55t`e z@1AC~-rKny_Er%ag+3Vp*66ud8tx>ybHo2H3jw9(em{49)8q_|LN*BW=rahtK^q>$ zSqY65q?Dz^Bke-_gkC5(I4De3ADqq>ZzPn}gJaD=ASL4yG!DsQe zRdI4&omn^xoU}`Fqs|pau7qqVt3zmYw40pqq$fK4s)PKKL{g^@iPvR}A_kwC+#n~l zo#y@|MlLLGZ6tK|uuPu`MWpK*hGAV%bd39`@%b;6cfTg*zqlF+0M_mF$sF;7O4*fb zQI{Wna|&f*7#E`AbF<#sD(M(}IVc$wOH9$C%qybvJ>ZBnQZCvk<`IUF1B$vtn$g_ zDsqJ|Xa*zthO`QAN!5I1^*W*N*tVklDyhAo#jTt?BD_>sySp*i|9 zs1k?p+%ag7U=C?gWbcm6V2UxPe6T_$w2O#l`V-@w0zAn2wZ0wbtT%Y$ozo(iarLY- z2`@l$L8|++lGMv-t(MzPR;K0P=E>FxGEhfnfl2)nsxlgku{i51mT>amXxa}oVHX_w+L1&UuThFHC zXMT=|q{mC_@i14CFqz2%jm|LgX;1OtE?H&qW4{vpbkn=n{HwP&e!O&qeel=cIV&Qj zVpS?JRegMh+%ra~dvSiR< zI{TQ(ZbdfkORrVgtmy`O)+nXFEH1=9TSO)J(w5de89NQ!QQ+0XFP@ET#lay%i`9XW z{3@>Fgb*l3{%Um!=GrmVKfZ8L)R~&l??X6g(#EPvymkPqrWt+cm?gGcbmzzU*@%Ed zhP34{*|P(|O>j5_bBTOXfzL($qno-LL}CM8h!E-lR1{3J3@0rXv;W#i^7NmU{G}l4 z1~{5RsX)y7s!0jfgkgAu-IT(4n*x*e_B3I~pb9yIqw7)}M}H;C(_M6}xt-JHj9c?6 zZCL7*^`CSOy!2WLp$zSVT1m~ zYWzG*5pUL%N>#rR((qns{4471@`mo+*t{SOW_N=91l9LB{&Voc-h@qWMS_l*KF>k^ z#Mw|hTBFOYjIJanepcwjZSio?%=*TTop=;piImQDZP^TX+0`MK)(* zj346``s4Zq4RYDA0qZg1cJQ&Ebpx7ov@hG@VoMd~d3mNmn$kHv)zw1W^NUI^xl~XV@Q*}p&rCVfN3@j%;!7dSXvzrUzEYIcEMycj+aa1p ze1r_K)b*myGut>U+rwmaS_>^V3m)+G+6x2@3v0B%u2{JqUAlPs-wyIyd=sN9A)Or3 za4D`t8U0>QZO&XtMxe$4)>5T}=m+6tkY?3`7s`{Vy$3(ZXXo9VxyZc(vrRE>T0>GKLoaM(T1Y>ZF!bJslYyZzB>4 zemQrgH?vh)ye!oKu=x61MYosLWrNW``1m62?I8@Zx={9Hp6lNAgl@gL7*2}>-NDGO z;mOC7z;f8~j1{J4ZjEz|V}IYEV{(~)JICZ#MFH@!HSTb-fD+iCQs(0Y*iRtFl1F&vR)YPrV*mS6&NQ} zZx5cKm|9Xt72f&uD0{)1!&swjyJy7RL zqc$&J3v(?}>ck*QWb90X9+1A3UVPl2Gc~a#-LkpayIPAN{|CPAHJuJppLj#l^wnZm zkPPdG$ox1<{*D4N#Hm7*D3QB$s;F@wpa=t%Gl5N3+zX{*GDv&?7DjeKXW>ED<*wA# zZP8kn&__tr*j1{oLy=_YYC6KG#pXT2F2UzQGCNkV4@i+8rL2FX1Rs?7P-}OR@^e%a zsRKFVsp>1}7F!I=@zGYwuhzB9^@95rCKQWofw@%#eArR+D+Ye0)`PjCL+{0D#ETb8 zi_bbcvvJ+D@rpTZNw+oLsxxBAfW_@{)6C<$*TA;1MnC4N(cv1gI3uF*Ds)J+VP`4y zu$yJjrkTAT25qtgS)*mpy=mh)c*wCh*<_HU=HSMCuQcFLs+$GKz_qoN0|e*%D1&`ypbXO- zCUtkCB3#R-O6rqXI((Sf-EzAeA+W_~N+xUeV0jA;s9=P(U=v}H>gtXV@a{v;pI?)s z+QUvoX3p$bmzW+BJ{7o!hAxD(l5tMAX<~OIJ86$dh7cD{SzRN_rm{v(Ng%{E*-RL5 zS)S%*Gxc;7P&K9f+2{mZ{}EzrXOrTC^Iq009fo$N|G{qZ-F|5ojBb*l>r;Zx&NoX( z`?tLn!_&UpjC}}%**-PUY9bsyO~bV*)_izdWn*fdZeDL+g2_<7p;vCnYnRUO4SAts zj3Z>LvW!eNWMir5fK}~;qyYB;0E=Q{niv{?e4?OiuEQ_UQCG<9FjnsXb=dwq(GA-R zie$>IGN&j-5$_yo4~r+@Y<{fx7N?uksHj#IP{J;=gWhBwt+zte zuub+UP&JVo07+HB)VB|Pg#dnWLb}Ytlgak)P8ixfBB#=w$F6rA;STeDAn%yDEHX!z zHaqNR_IT%tjqQsdPqNo~vJE==z>J#LVrl&Zxg-2VD~;N~UGiRa=oVKcww2oX1<+qi zkPfX49e=`uzOnJK^bZu_Nl5koyTdG*}A zrNwr~$LNndw|NapOi6dfSNqpUERmK$h`XrybDW7(EE3)!5A3IWCV~$kcmDVqbX1_d zZoB*2BrS%>DMk5DI!aw25Sx%O6j;fd*GlhqJ+1SnUGhqED|IkD1#vvB+_nOzpz*aE;uc+ zdr(VJZSH`?hV2U@7k0jh5sApsBrVmEfH?l0aqI!hbO%8z>w|7*!VqYI$G0l;9YFPp z5%iG?;ULoBt0}N_n(z5~=n(IHqo}Dz5Kg42o6ft4!0?!Xwf`u$dfT!7CUc>>5*G5; zdd`JfJtG{w_Lw!tNB{B6vPrHru9a%(MnNSGp-Yflk&lYU-7(IaaZ5_3RIz6ZUJKT=pxOTr{IXd% zPMS+UCJ^WoB`E8b@xUU!f<@b85`B+3IaoI2F})~GkP!Re2%d!V3=uoghD5OY9zG*- zv&GeoKh6|tB=qWvZ3rj!lAFN&feijTX)v^>jG5cz#p4#saG3EGlAA{VV=C>ZY&L3i z#RcuCoZ}|CI9?9n)i*$aUW{z3T9eFP%Pm7tH1FUXku~&aa5{+yJadt{Y8OUW42$>q zfZ*go{(7a?_kyov6Zg_2yO=Ms3OGpWo-KxyiAaFQ%3$-zgRKrjlQlq7ZXc@EJjFwE zV`k8$4^2H<6 zbz#R}KrA?!4RJ+O8js1EAx*&)9%qW zv{`>gzxpc?Pbc{L=z_%=cJ4Ueto0eW5TphW6J*x|q>)K450JA=tF=C*+XQoUB`Uvi zVLmjFQtpjEJ80_CU0tAkV7Fl!>fZm05>T~^_e-~c)jL&l7MH2H^umJGZB4L0L*dd0Y{^1z8s|T>GMzwjr z=4-HpNYfa}Shcu~6Y_I(wyUiCQE0`%pH!1wA3U;^jxQLHLSVeL%`d3~vW9i{Sr`7I z5U&bC5I!l9J%0qdeWX;|3-9>{kyWbKP7XVQg-JgCco4f99mO``<$%p#-wJ4>Kpd~k zFxR)@vcbzB)UZg*TaXZj*1M37hB3}_3x=#!{*6@vf{+&ml5U9_Tvc7cWLvcxTeQMy z(R9fSaMM=%qv55^pICz)&*gHxTVZ7Y>_5D=X!;BWr}^=r1|ELsLrmLSD<`kVpehkh zs?Bc_Nt}HzO>TXbMBQDy4&vSE$I*2p(*NmgsdNg!OWv zB~CS*?awu}XwoxDyx8PbFp*$Pqe+V%7r4tkypy%SYJAzdY>&U%V%Um~!`S~q2Z=Zp zTUI5nc18XjEp}tYqWol7H5-_L+vDGC?t~mMTg(WI54GQrzExjmterA#d1HvZWT?j9@DkfS|i!ulmrWi+g zUBW1>;*>0|dK#>N@~!l8_Y{)`1a+I}(4Wc>Mx>n}=GA}KhESKJ+gG2T>z2(5xFrPR*q#a|q%dmQM}F9% zWQ=;Yjg$WeY8co44{EqxLR92rb0X=R^>Sq!E0aPzeIUSBw_k@7Py4x@RUQQGk7Ctv zb1OzNK=+0wXz@gO084?fiWO7V1;E)fLGbm&fioJbA0pU>h?mD0^0-DINsR5GxAXbK zG~{tIZ~kE@SHMR`^YBh9!=OscxMlzUJ`Qk^%QR*23ap$&F+KYcpp=Qqt$fH}<^ zPxqX1wi>G)%%!%g?6;NO4G{y64R#qzo|3&eb@pH$s>f@-G>&S8rb@wtbH zSvN6OY`uaA#mDoSGwJszswA&t?}AIumPO>LCU|6AOP!Hhm@3F5=K+;AZf7qA>^mTj zrLIQLP(^{BM3LeX8bBM?*TvN$KYR5nF==tE!-c)$EvPT(;day}^rJp`1_`91&RgpO zJ*O6q?opo@;#}qQE*I%wV`Z}VO&CrCLobO8bt%!-!~Z*OKN9iu!Na)5-b?80YI-_NeA7o8jV*^w*V32Kf2m0^gGjBWDfk z%ya~>Sn6&%=ik`To(RtM*4s1HO55)}CYsARI|gg9-l6;poHMd4TMJ7uK`!9bS*51F z?Vv->(G*#5R7OYvThcY3pY&FU6u5Xl(Lfg(gK)z;9Qa>Bb)ukZJLMV7jrjop`f<=j(z+2WP_!dx5!NE})A2)CK8z>&e3vfg@}Rx%3Yeb> z#6zMCyS1|H+L;487%S<;e~Z$rOcL@Yd(91rPWEJZu++JE1$b=;Tsfs)R+fuw-z8m6 zv$I3qq{6I1)o+{Ds@m}NXo}bLe3E@Q!?RT%Ev|c z`UwS=AbUWPf^NA3*{SCAJiYx{Obs8`cVSOS#3Hx>8g#|-4Igx zMy%bC3j3LHFxkmOEb^a1BpQnDrmn`Qmqg#uU@*nfD-JzNl;T!Jwy_-KKp=o!4zj1Hy%t7k95B%9k4R--| z0QesLrhuT){&sSpQ<+wyK6iO5gi;U~U={YSxziXg+=Eg`)b)bxxW zjsLZLc$^Q4kfAZJ*+&_3e2h{O^uN>cm-?yD-_{o2zqMiaM%#bH#;w;euKg^txzj;M z-AIw*IK@4>Jsim~!Du&YR863b8hnCnrSAGC`Fap_H$D#30j>O1(9vz}CXAB`o}b{- zH<_q2iD@<)zy65Abolp2lHs6s3h;M`PsWn-Nhv+kHrQWpa>UU-Q;n-arAbA<_K>*O z{U$d@AyxXf;!M7GFhcTK#=025+JQ#eAAfrf|4ww=Pk>;G9E>EYF=)8M6LxOkZ4BJtECAyE zXCNWy0i>EL7^LZW59V_6c$kSDfSN|wTD!Yf6AqmTGY}KX_~;sk--39&gL+t6KW1o7 zi`u``>Yg{)wG@~nyboT_b`S_b?EwKSpr01b+Wg?XTz5k0{^ATZ^7*-kE&Ho5&3qxp zL~PFy-sVIpS=z5Fyv?8wl*(}IugyXg*Cul#Lb7k@kc#>fN;fczeMffDclnMLTIx8fn*FL;q5hJ}Gz zOM$+W2{nGlSf<9>xjSQjk46H73ym^?biQcoD)X+b4sIX9qUNzLQp$@es1UHr18-k0 zT&&tj&WUyN-+!32wxWZzwx-^0^J8_I)-cDmf6`@WIi!4S)^-CjpfbK=!Liko^VAhr zLR%;wl|0?uWNn$3!Q7M^hFy!Gf8_F8IbkK_i3X_B^z1H9TQt$8^etU|)vLMj>%COY?@x8j&! z<^jH%zumEK@Q$%dnaOsXmTjhcBAqz43;V2Y=Rf*P-T`W;*SqM&RX>w19E)Mw z2MAg};>k`mEOrvL30N6up*U|!q2f_^EhZZhGdmfnv%NPxW)xZSY4p^kh!{<;Mxqlf z#CBd1pCqe0&}%u!1HIxm5+5r`?z)dah+wTA31qj{6WmGQhf1!Q+&;@D-*iKAZ=kiz zp9!JlsRn2tUrjNZYHF@m7PAF*GqG%GGA|^okx1#ZIYw0Ofs373S^}xxYIi&JT-G15 z0WTy@5vb0}dXsQ(8`AB_#caGm1xhL16cE;eKcUF3?q!E7joAW5e^YIVE+n)SkDu7i zeZocC!m(@HV~S3-zV~2PJZIT%?O0ed8;*BA8?n|XUo~GZtUj6J$tF$YT z{8@M;BZN3$oU$XRk5A%VSX#W0_O$_7AGMFhlKZxUV9N$tNybftvgbpVpDs))Zi0qn zy|?i3u`~>JoveV4?r9Te*QX#e{n|XocC0pDhXE2yO5@nYclijXDxk@jXG-NWsnu}- z*VVdTa?vrt?7jZMv5SPzoX2>zeRU)*!u{j!r}`A+pA+8>xNXM{mx!!pj_%SanuICD zkD9HH%)gUI^1z;XVC#^5aH7=3&o7Y=j$3l8n3XN=aI`k$D&sL5`uhmGH{1mdc`r|! zHXDr>4=2Uz*f#r%cz71eK-{_sle{!Ge=?P#>j{je<&=Q3@Q5Mo8YltYY_&SBnMR5Z z$_#O zUacba@C%ji*d45c_cEIUMLT81U3BU z8NbH_t!_@LbkoibAN%(|2b30({>M{k$=5wPB;)^!5e*TM0+f10&_lp%MVWAhVkkcB zwbM*AJ=1>c>Pe9_uRf-8t(z}zH?O)jG}o>^I^A<~4enI~si>;7o#U6hl?{%_W&=&x zvCtP;FWaOe@Fo-4Xg?aU)`mPb9$!yBKZ4C$TnNVm` z=zP_xsrRhckgA1MsgAN|zR)ZIH7iTpci{+9&!*S?9-rC06BH^YMdJ0OBO!vY%$obs z;?rx`Xlw1FUXuX6;qhObb}Z&aVH6KqqJL0n>ZP%k7PBo>xcfVx+KHq90gEIegixWf zJVu#qTeFdU%|D;Peo{>@9_HeJhm3WLxr?5e`o;gJ=#^(S_**T*c}gOWK`>=L6X!nX zjtkPnixhlG$f-rSH#t;~R)`Q{iR|xU-4NESNTh*J7lPsDF#*Egb79+WTFD)KJb1(j zg^`Vpl$K6G5Ejl|9M(gL?AO`6yGQ@kmbut!pSDtk4o|q|7_dK!@@~of=I)2JmqS>g zzaNs3klkqj7o@^C?^-A*XwZND4TCSSRuxuvP8r6w8JsvsjLGiLz9;^^V2aIoGZ5;0 z6kWFw9%QaCnf!GN<}9k2TYv}hg*ZM5rxw%)wwcdtUKi9S!k!1$Er;kyb2Y%{L@jXu z7Cwumj_ON(xTC`4L65czLh5)x-d|_`6JL)+mA#Ml+-LVj$#9h$y#Md6-oxwj|0+rz zP8@@0-iejR)W}CZGM;8G5Qv`9cs7bmZMDW)sh~#SG)i>5NN||ijth9xDFYMiPazyR zZcf#hniJ+xKY-vpSWo+|)_f}99)8usQIi&OWx6|SQ1Qo#*w8i9tR%K_U490#{(fF* zG6z4J@iGK)6XTo=MgatBT6hb}$xwT!2S;G3YxzKl!t~N0x0vIx6-HEu@|f-Qfewdgiq4q9FCIvjJM241Xh@jD6ci5QfLe!(I`7%M<&9Rm?)jl658+QS{X z7a>nSuwTj(Y$B2cD7M6~1VVBfuungnn*7J)QmJdfoeA+jF`{^3i^dPAR+?c(u*Raq z$;z7MD%hH$JPwr~T>DyBt?u%bo=-7geE+jS&I`vH7Ia9i|EX`Wzc6FVD=VxVIU(2(>FfdAVD}d;7Q$@jR0)LY z=NAnneuS$!gz#v&J$v`rKS%kl^uI$7_oh?_5tJ@}C{(C(1cZdT?)m%c&xdyHr%iP? z>=LGo%^Pl-resAKI|c=rF39QB(uj0B`y<#z0~UAG8x}`iWx2l;@v=0Tj-zL)7X3+J z?XG|IxM=3kU+_l@UhV_~RN79mIB}=0Eq-_CZq>20UsEgJsC(`ig=P)?^M!HA6ru&%xz5;^e1Cu^k<^FbsMwdnw)0+yA)n zBDpT{oHr`QxX}k=X188UtSBDJgJUw6BJ5Fdhu~dkP&F7hTCqlWUMZ;wr|c+dcz@si zpj2BE0Pw8zHZsP`oZv3~dI_VWC%!DkHhG+8Qa>Qpi zLTgQgrfg=`Ri%orf#!2a1PyZ(D5lww%~0LKBTY5lv>5O@O#sCdhdpA-C=fw2h81L^uSrs2V#e zywWb@sTpJUxI`z$jO$nSWo51hT6D{p;Ag*mAr`m(0TVnn{|Ya*4%B`9nIUdTh3BB8D>M5H#Dz&$q>%aWztz=5wcY3c0Q$G^h@0`U6kBPqsp6Y`ms6%EUQ zU(cyNE8{pO_+K=DE4pF*a&47#D-EoNO;{c#JJRDHyXi=J(U^(UHW>ik?d<8}fSD@& zhE<+?H}jeBXp6l*Yjn!)32(;G6Q~hzLMDxVqFQ7&HxD4r$N(;4OuQ_C9Hf2R=_YhE zI`pNLBF=~(X_1ks#hy|lm>lJ49`kzHhZbg9!`%pNaPaFdBu~!DfWXt8@dl7utsFjg z4OFd&?ex9lUmRy6UG^R8ygvHUiyc^~V72$=v2m!*J;H{_xZ81x%HY>DRWbd|G}I&! z6sjR2$++!b`k}59lYzVPM`?^|;C&P(Axwz*HB8=3vQCRzg?JGdKQ-htji$RS@ozc3 z4Z%)vhS8ns^KIfV_*0rA=iIy)naY6Owp@zmsWY`n{-XK9zI4>kH1mrCkj_?kLB^8J-%bAY7SM zDIM;4U9J*Jr9d~7D}2^?yVD}UI5VQ3q}`{1)vmGWS&0}-hTUyrdYz8`%OGacUAU;* z(A*WQKS;&g8S&1SP1E$N5|>GFjHjE%a1oUplUj>ZECadL zPGiw7A0gZg(~+Rp`}mWJLH-2=g53ErYPeEH_C;A>y&n|LUAi303h9hoN;YiSW9VJ{ zbvVM(fig66I}kS{_^(L=X?CO$N4kYYNtT8~i}(kpUhJOITWq!Li-hjKw zW5i|@H+6qg`_xcFn+KB+mIIvJ8OGCE{b(SeX`(K_&b&Qag1Oa&wCJ>AQoIWu<@_r3 ziW%Gn!|F`j=QONi4jBp(Mu}Jm1%nBQ_C0KG!J|*WME`y{kaac3O0ro{ck2Fd-CjPM{EL3nZ7XmHsbKGK`6UNB}ABe}s#9@`z5Z8M5} zedE81RwZ{vf#W`OM3pMv4|^%M)f^5$v`mx9u5^%Ibd9!Hj_fN*kdcT`5?43L6jHOT zKwEoGey&8Kxd*#;b}OyqW^TbkRH%M4M#7|pkw3*Tt3(5%&ggVq-^Wf-_j=>L1UHfS z?1?(dtVJg6tbL0ZDfj=Q&jB$m!;pxEi7Bx2F*o6!+HT<@!|x3KUMaj%`tzYANylr5wCy%wg@nDg@?`$E5ffd`BbgpB# z=v_D*QhC|ShH`@S!h#DXIMk9>3soN;@j>FVd}*aMtCaoPi$)2!s{n=gLto={eWAMP zb*y!^l~b0y70!sdEDY5GIVGREpxP&U4aZriUF#zYG`B!$d}IMpD1Y4O&<%yF+M-y< zo`|+2&|8dMn|g{V?t-dvZ0}zZmV#@>(~0exkMq%rloP#QI01u=Q5wQ0H2QbGC)>{E zc;ch?O=TA)Gk_KpmFUS!3+3^4JPLf_8B2jf55qSh{K!W6hJRtgMC3s&$On20s*4FX z(80hk9S#=%A}KX8D#R$*SZvRzY|E_|?8}ScP)en>KhgybmG~Z;1WwC@8Tyv0(H6Q) zP`-?|i48J~k1mMyrVeSoT6F|p_0gh*5F>w=rAhqA(6Y3OKbKOxyyVzDCj-aSKCa@a zt-OiS?U`f>$6iH0{%&BMRv5Wn@lsrJ zo&%3G+dC=1NVWE}tY0jeLOu$7Fy`h$j)Ivs&xz4|8rlAWP@Xv?=WFHZ=Ol?>KrRoN z0MT2H#7RB*sc8mUcS!{XU*Ap-tFFtUSXv)T*2*Lhcyi(2vw)dc9isURIS6@Lxr1_3 z>g&e`JKqc)5wp6O^(z1DswxDr1}x>GLo^EPJrzJT5wvqIZGoSnw|#{d8duGGJqH~A zyHf+03qq+5A_cNaiun>`jKjCrBS8_$-D+wc(7xOxFqv`EG;B|3NgXaL4O zLJ?ySO^aEN;gV)|hw^1*5R$OR2h$8I^MlCa=@@wW?^9J9=FY`pcV1#q=Fj5P%>xK# z`#fFWv$F`7&v?`g5kS9NB`tTON)T>i+JCb4X>Awa z`WqNQto0`DLv4`U)6jt=-g#ZUwiXTXCAy^y4Rp6){0zHBjuo0M7?t#6!_a6FJ#8_i z9Cd9^bMJnSscs=7X5uBPjihsAefs65#ph^tLNZRTl7kC_g7*xAPnJsDyJL8kzd|$6 z266z%MDJMViFd2uadp?wse`glQ00H)xZD54-JIxXYLl?U*CSkNe_WN|F6YRw=rWG1 z#mP2|I7Z?WBn6iYdpj}*q`XL$O7*?+fYT%!_Zzj~h&G@@wPSqD=pW9DkwTQavHKF^ z7ykLUG)QiCduQz|Kpeh-<`x&3XnXFhBtYA#$7$-5Su+=$O?-9nQa;VC4=#{Ez2KT1 zcVW|C{H?57;2?=6vFX^@DQ|%YmnU$`{9O_Fltf8qB`2N){=MP^ZvBWg!SW^DLx#8> zS|_x&r!L5f$AX(DQFfZh*4rKU@xVXBvn&<<5)u=8HB}#z$&&oguw`qFYw_*^;UEN( z2YL?vfrpJmJDsfxLais^m3sm)@B>lxEZuL%=L_6`4*6D$`Wi53R6iXCC8n$kcLI!f zkzFR~;HXSluol?Ih%5K}DHyata6iPE8;sM)9LNM~jvTZ@n${<2w>#px0ON7B4|KY$ z$PF6lDu`?(nWPU~PL=i)kmCHuAq;a`g~^{v&?K_ycOgyIVCpa? zNcK)TM*lAQblnfAM3U~<=(ntFWUhp{wZ&f)ILp!p>Qh2-WcLLDdQcs$Cg5yP0dM-T z-8qBgfYkgZgRi(pW(O{0jl-6tE&(&4mT?0IB7?>Cj8nlsAF+Ro?6SgN8NF-Edno=@ zjs25W``4tS5atD2sA4m9CKs1HQtA8uU|Fz%^4GVnKw6uh%D9y3laJ&d4^;G>B^z>i z&fN9XWRfI-j<{w+A89D5jE5sq-_1e>I%69T!0tDxy|v5;Uho4mHfHL@I{h{yuuU4u z$Mr)uge=5R&d1JM%g)P2kI_P6he0HEH1=vig#g%vr}^q=_6mv0EaeyPl{p^W75b-f z_6D`_$L+jb4!Ac(q!?;S7r6QnGC{G277j5V=;9ZjM1eb)&)&;5k+ z$E{o$^yxSZT1Vy_vUMSN0Qy;m5I0B-Q07|-SeDy))0(rCRS&&0fr%(mB)C;bVVD|~ z3~+cjjJM~74w~o3GfEDf;#d`FqR{@N*-7Zg0V~q7x;>es18pGzJHi82(@<0Pp;~vb z#s}zO3I+;!qF~jL%3mK@Z%!r!znRQ)48ss(7P{Mvi{6J*W%h2wn8FfXkl~A*=tJAb z!Qn+5n8$|mhw!|L4>Y+8l$1cwfiin+3-SHr!+E}{M#B3==IIKq>I|?tAJ41k& zqAAXCm(3ysU_IwbcBUN@R&Ck(ngL#=l^wI}w$8${{)*wkeB}K1T;nq($e}~t01`Hk zcMF6IbqTnM|FbiX)uU9?ORABsm;77wR#oQ96jh(Gh?p^KQ2gkHM20enc@r)0Gr!Lt z@DzlR0O9ktmBqa%v94Y@W5SY(&GV~x$P^u#4IA;_heKr_Ix|KZ*3HHC4r=9vJEA$5 znWOs!L^$!gETzbnpG*Z@Ut@*_%Nedv>r9bnFZt|}`&ETimcF&Vg4x z-L1)SNZD)~nv*px1H6_M*y(vQ$`>Fa5h$Q-j&w+4{ChL!HAHf|{YR@8dx<6&lY-Yz z3kwSfss=L@<3KZ-n+x2FJLJik>aafEA=pLqmEn~Gt`Og?ZL-2R55vt_=~{bNr^}`) zKqE#ZQ@L^7oHiic!`m$r&)5lJ@0aQJ73*yQ)M_DQ{E?M<=4LrJ6F0=a$_|2?xNlE6KA$iij*9k zHxzo`=?n{$282)Y`|gUHJz6&BY*L`rxz@s$lhkgsE#~5U-z%ZL>Bi$_%t4JrQy%n zQE132TKRw!;*|WXhS2yHP;8de%iwqjQRTr^Y~p@uOx=(QGK) zPb<_^Nhj|H`3qwNmQMJgl?bB>bS%M^6xSMGpmU_LuLiu%udVdV=X^73F4Ig_UOL zM$~Jy22p)cT=P}%T!Y--S354y8?kRj`R{sw6X#&Lfv;r1bPhCjFY;r~{kvB7nZ47Z zLt0$5(3oIWlqz~9hQZ%dm>oF!p~+wSmYLaTJtZ+;+sQ*M*xW7qO)^xdxM^XNYON!r?X9G>J(kP?OSOXYYn3*7tS6sWfGiI9`F z74L=n)ZQ2BWB(zFqyTzh6%K}gd;*(#E2+79Y zG!h6RiWe@ti{^(dVEe@-Y=fE2a8jedSFx{tkQXhMJ89JcSkRDs?g)f>p3@+VYm+@X z`2PY=R@INn7Crak&E|-+OZo{1ByEYTLw@2vC}_2l$z1S;EnU60q<@Tj7X#&KT4)ZL z=!-sFoMc|-IB5>M%*wPq0j2T|>Q-Ii8YBAzQ+mIl_6r#PhQK|zt$ zk)Hz}I^2g!$+92le@c&UoV~5Eu;w|znoyZrS2z3}v0$CH;_CX#KBQ_Zo0NIllQ+mB#>u9ZDZk{ZR$w$?EXLo6%fLhR5{Db^`h|OuP+9f6>8m)wyTO0^ z*TyBkOtnO#lgTvN=q8=K;uYU8(Ye%`O;;4v@S&Tej}_?T>Fs9bpS(XuZ7yT$Y+gA52Z*zqsX_D=O|BQ72#)P{$kFT z({{kK#N#-CdP$PX;NtnS-_;F{DB9se>YQE+BE0<4YFvTEuh_#dAMcV%7^Hphe+ZMP zEUi=PVo*1jKCA@tT2viby+8aed;3-pSz>2p;&1c%n-cr|@HMocG`Dk1yB6zflwA04 zV5CyjG^UO;AVgdHm&&BXDg!}S#FE+9i;48?Q*Rq89tu1&QhKBq7l(q|ZstrUxmGcs z%p~Pdh$37nx>-X>;Z!UUz2ZRCYYyABOw-Z<#X&qiS>C|Se$&!mI5l$Ho@r2dnqnr% zT%A6;1(w=0-)55;Tb5znEvrAEMJJZj>->fECHP-1{^HfHCe^vNh%)Z!L;qNou_!Lc z3jeRVzB(?BW!oBehXBDnxVsbF-CYKE26uN2?(QDkEx5ZRxJz)ikDPb!``$V4zW!ro zx~JFLd#|ec_3o*j+EgB7yyZ|RDp0@6Q0?n5y2K|Pbs@9Ct>H$i9$LbLfJ@5qZP)z&XxnQh6=Ctn(aP@T+FH8K)uksLvh%(VM9ybfCr3%kR1 zI4Hebw$3F|z8i*m3F+|>7<$@)O`ngVB(mto7Agj6!Wujx1_6({Ib>Pwt%zqtXD!vS zX8~P633genG&m!Sl2w#cjH9CMNlLm*KkegOD&tB;?O)66K4-yXTcKEm&#uK;?7jl- z-L`uYZh{7ZL7CxbeZ;NeE6a;QgCJJAcme?H$%gC-yR>1sfu9>3R-RKW@0;oAnGY72 zA=pQ9q(T70I>a%Ui(~(BPJsbGrK3o!g3yr&=ZEf9WzXnmh52NVqF@$p{^l~jLM@f` z8eMNvJs2;qdGfPipopy>dEWgJK#2yTXZAV3q|*2D#Zw@w!=3QqH#H0P@xFIGE&-L> z5Rb`W&1Mblrtv2EY~#1EVFWd72E1Sg@~dyeVh{#-ngQIO78m2)I`6kf*pNA$Zv!&c zE$&VWAy0^^Fhep@PGwOO%g#;g;2O003qc?TX0%~Kh09*ILZ}cP`%%HbRcg6Fj&V=v zVFBCjZvF*!|gA)+h>yZ@HI zB&xCZGCi+nvDojQK`PD>?13-QR3MDcY!= zq@vBWmIFItKjH1Gw`?ypd-(w*gNwM>H4NJ3f1cDnh8()QVPM(~^H+(nltE4B0X;PmpiKf=SXeH+=%o0p)N? zJKy|1z|?(vNHpOBMoyb2OKXjq<8;LTl_~q$px|89?s@61DwA}9wugBxqGs+8$i0ly za<%2%?m4CuiojmxNK@Kan1uRE@!0hiev99FAB^?1a7?+`>21aa zn?I7Mh9XibT5E>J(XHr#pKPG`O}PaHtx&ZlO6{FmlV^6&U+1M=v ztc7t?L@BSQo+tE^Fv3~4Y2!yrRHuOH=P9hh=4ncI6oN`FsIR#}{07z~G!(+Aa5QD|YH8oB)RUzon zZIn}XW<6Jck(0e*0sidOjIh*4q2G>+#ck>Clb%CqttS~mHKvurke)9Vw!={?Nz(ov z)+&3SEY-A()V)C-zb1(e_N22_3d*O?`IVpYLH$t5WZRO{CBG3V*-{BQME6bpm}e%= z%ep1+?&ln`#|YeHVu|$K0cyfo-{BIn#Ppawq2WoTt&qCsRTmbsbT6UvYY4-gITSvQ z97(My9B7$XNJmVHp-6M0F8_f0z0Des)>;JA^{bniil#RZdnkF=0xSguh;wC|ge&?< z@!+I!OfS{cNqXWr`drB#dvB#G^$t=fvu~plzP6BeJ`B1*E!=KqzM@~jjEWh=iEhWc zNf7P=gV~!exxM=eoA;t@AEdv*df&^xC%rChuQd(j?VGK!tEavSaWt;9-*QBl9Hal9 zCkPG9@hX}2$Y4U1jzu3wQi^?jWPa!EhuI_%|N8mFnJ;CUYwqPv>HFX?M>vhfA}m=h zdN}lrYBa9^o6LI<0$c(^je%~S0AWbb8sj6TG*&x-7!6*p)bh^@Qkh01TxR}hq%cT_ zK%IjUg+qJN;-Bb;l;m(*o-~HzsccBcgk-55sd2UNmt3BN3WYv$G}26hQT@azZ4xW- zsj<3%W`j5$Qc@4uN~#IEY4^3cxq(ujGUjMxuM02z3d6yvE;AKxScK41?NN9Wl8x6|u9I^6Eo?waLcJ{B6;u{iPio}F zX>!Lfd<^H#o!LiyI=_>CDy!WPi;BVCr=j=Djm8`>U!kvAVtW2Q7s6R(+3wV3Vhv!J zQi+%lsYwflNFAwB<*%xuQUV+gta^hxcgN1_~m zifr~2CdV1!uA8&cH?9OJ77LEnpZw@D^QoV6A!o=YOVZqPk*kbR->|pVig2Z$aL zoOW3CU0D(nbQXWI8ZxhiygZ#)FNJfHwK~E;bw)jdiRi;g+m^#(w)91w>+B4L_0X@k z-dD)=i$WWI){cR^HltQmuXWIpy{wRW@;INJhC*=f;${EJCLhfZdIkU5Ik^6c4LbY7 z(=~})Dp9lNyz^tREG=vdDRfNC0~!;Yb?e*{hcFW7$P~DNb!0V>X?StqgnK=d>xp+Hq-e_ZW_K1;dFCPXMbtcp1}l%}lOavWqisO_80E-BnXpVc1uyY# zO~LCEo~N71z}S63adr+fcm7+)-a3BVD}Btpn)=``l1(`wDpw}?siaX12&F$L&&JFl zfe-Qazk%A#GF3Em)3-?WF(DdZYFnNyic1ZJRpWC+#H+bQ>O}efQx*7orT?Y4wg<)PT zk`-!NdBqG-R>Z+yd!~@eAK`1kC}~JNRJfETP7g4^u6`yWTe#{_DW4+$(ox09?A5#_ zuK##8e%yoSGpU@sFJjEMuqfVI!qNN+E4EdARUPw;i9`lt z08*R)GfeFsFp4;oRi;!%!$5Dja0x(f+EPE-#%(40%{f`=ZaJMOjsU`cC!W59#Lp^! zm)bLDz#A+sW29@OFuEEzYhj&Af#nHW8pYTh+dVzd!h=z>3ERq4@A=(B@++tYMIqAF zl%KzV476S#`5h9hVmSvL^T{wRVz(z%By6I5T=Tza+vge#zHqK z^zAk-3#ZU{(^+vX^z8KO+_X@92KY;rEmw%`|4c67pNr zCa`U*;KCpsA~4HOh;CteGd4KtIyS}6?MyE&cimchy)-}yJZ0ICBv*qnk? zLwSAE`GLnpIrH<-MKbDjsU!ad+FmVHZM9Yj$d3CcemKe$7oPt%MB9l>NQ!uF!<~Vp zMQq7$Z)-1=DWUALA`WCRESY$iSeQ``BPp^)bdS4bl-M}}<1hyf9or(RYn>)EZF>+| zX$Mtk<1#QFErbZ3ShW{T;!>zwHB*rpd31fz-Ur8I_<2=!HQsOVubUqqK?cc~bFKU! z)fO;odc!wpGfeC_X9_QU2ha=)i>C?!T>%f;Qnr<*$`r*#J-Z8DMbd!T=EQnO&b$eO{Uly@1*ITa~wzKVM59G zZir&4l0C`%L0W;|0F1!b!<2RbnX;KtxSKf~5L@2Un3pZ~U6!x%D`3OV2DJh663kJ< zlfs&Pr#+J{pHUKpVvRUu_rlW9)`&jjj&jUU%TLZrR6cW}J=bg;Lv14H!0>ni+-R?Z z&;#c(tLh8IqUPr!n#O%BBX*x*!d5kBlxdmrNqaG()LyirlpGuMfU+|j_=8gBy8yC9 zMvx0!yr_zvH7ph*C$lu2biPGlF}uyPAJZ#5SpX--4X+8`E1luP+XmWN*{-!%14V_b zoI$~!iF53<`2y>6TMuijWtQ)HqV>*;nl%T|Qg+5y8MDEC4~|{P+?7K9xXP2!A{;&Q zt||D*qJVL9R(C&>;NmzI#8D_Mt7g4O^Qm1VRR)N~gajoSmA2F7n5qGhMw9es0{+Nnw;RK?RCQJC+H2f+==9zOCNidjy_V2xx=}nT@vrtwYOV zFTDptC&JMwwYYP0o9G^EGL}jNUoDBP%T9E%} zww4{9_x*edhqmDMI#bSO5>PY;y!!>}lmW#B(icS|#6|Y5b$&;YO`g&Jo$v`qF4OmN zzQGTZ_=iv^lJi;WytZXI?w_?>ABqA}0{uZM0&`CiJuIk0YhA(88$OCROz7w)vnQo^2MWV99_M2w_V>gt@lL(mLF1*NxgAj%=5+QP>pTnOAkQ(ztbkaK1 zvA)+Z(_b>YU!I?yW9J+acE1mG6V?j6O}$S}mX|oQ^Q(M(r5e}94QoQlmGyq;Mj^7Q z?&e}u(!c}DsgWtDT+9uC8SrcLrW9h;Jg{hdV8T`)p!v0OfbtGfYU*A}|8;Yj^V#tSTVRr_gX@p0tul8}coh-=48V1!1j% zK2JR&&CN-cUNA5HiRLxDG%q8)&w~>>_&(dU3>f%Uz;Ki22yEX z3PfecL#9^}!0`zD8!K*sXwfM0k^G#pTXYt6g4|WK%kj~iu0zEWMZ1aTo$;him zc<%+((MTkw*9W0(VSMI0o(>I_unwm#=D;&w1&N+OCcN3fCq-WN)HCX+L!2`TO&!yE zQdx?K7UG5ayT!SoaD+THU6H^-a@UVNF1w7hz}4Ns%lS%_5di$zU#WUnsZgLWzmn6} z3VINQfU?1T4uVm$L21Cs3V>KPW5=HG)osa}0RQt8Aa>N(C2Pl3ZJ9d$!TcnS5Zx{R zX(5D(#|tu%svbn45VzEAyC-wTZfV?i>Hsb(3?O@uG8xTwTFhMZ%f4?{mM*qk@y-km zyK4O5^L=^wYvq!hdw1ZIw~F*g7^9-q>BPAK_>yw2GzyG88hEGNbPm~UKLYC5bf;;p zJSC!Xg#~#6yt3Y6zSWa!9y7~#Nl~!x8z&UEEi4~@cVcXJl1m4}ymXtD^osFjf&#x$ z%7=vrSaaPEbq2O(QM`Bzopwtg2H|A{|4*C zw#20$0dd(i6LY2zMz>zafsSH%ky7H>Rlos+@QpFBnr}sg1s|<&>oNT(*f4+u-+2}+nNpy<5|ckigTTt`QF`Wr7MR!(pNLfCQxjT2t#(k#-<$16qANM z1jsCOX>QU1Gnp2Xh$1^R@j0_PJvsY!+6p`g@DGdpd_m0u5F{clta;XX$~^y$v_2Au zCR;h5E<@0E?rB^Z_7y8K%KQqR)!}4pyvi=dCXt1vfMBY?_`Zu-H>k3^pqvI~puV37 z9sgM_BW+*BXDVkCLQ|nI<}w;k1}kM5g03De@rY?kXP*SdhQN$EHcHcc6kJ0m>yE1f zA<$jnYyPXl_m%vd(-<@!_9s(1Qt#BWJ4zS2h6rMBrA|igr%zfk0KL|yiQrUbr2O^h31w&J8czPL(S4O*-4ip6J|%Iml@6l3eV55x7jzfP)dD`f&BM8I_n4BrSVV&d1T2$>Q7U@i znGw}o8hx!}B0^n}R4s^LwNCGscP?X2zoz7jTrxm$g&`sjIM($Ac43iFh_R~sr+fEy zBnVKu(cI7U^JBa)`tU=r*|F-PDrNW%(0DWS@O9}+l(VY0N}oIPqPxDb)AKx%vUgTD z|DcW0SsW~$+l4EY;nebqM907^z2hV@g3}=itM6q53^*emGT;x7>#GDI9W*T+gkV?pBBk!_PpL-2^^D#M{Qw9B7k>k81{qd83Rv^Gbz+q^XM%7Ss83dbM-2~;XND$_w>)su?b*w0 zHU|JyVH{<&V0i^f!G*Y%Ty~Fpa$IkS8{>CUw@~$+x9hQ!Son5*ligp&11cc6v$zq8f>%+mSZhy8OnElJ_CX}1 z?Y%t&QGuSBw!X;bl0KH%e4A_%`faqDR7m91?45U=zNHdrQ)fM3=cqd2eS6S}vmlf` zorKZKZbXKw0W#11w3Ku87czT87&GJtad4^Gv=#HNX^v7Cu+RaxcN%q~BK$=ob$n2X z(R9~V_}*(Sr>ooS;}aC@RB3T8af4dig4$rtJUZsJbWXDN1$T<@a;EU6>3J$D64YXX z0T;`Cg5o1pQJ02N2HBoyXmpHbUk%ZNu~f#|C|Pl~ZyG8I2SQ7bB}_Rz+SSCOwII#C zu-c;wx5Uu=OUa9;Ga#0|F>Mq??L=GKB+A^&iZgdf-2{&H=)SpZ0A+tT-o3;vbDOaM z7r(!U;nTA$tECxY>f zfUdcEOii^GQfJ1M!q(uzUXhURe7KF@4YXP?R8wf~7#heBGM_JY=2M!F%XEcxIus;G z>aqfvZT*9<=UAB@9_VIRfn4pCwOVV@NkhVuXp^5nLon5BWlBO}bZUww^&rsmJ1J_m zIYbBHq%JUY*Rjx3<~M9xu7-8dW}tUm9VyCl#g73kkUTZ?dut(yaiJIq%g*tb5d7-< zWm8rLhM$YmCKC#mdvLACf^0gri1 z``Yjir|PJmC^ATQsUFzq6@GK07m|dZB(~0v-dXs_)IPBc$nerCl544Dy0F>Uf1P%h z^B59kYtOfF4|v^_v|NS4z<`IUWyx%4wYUOFm3 zb}66sAQ4a0dj@}`eI~90cg43CNu8y`$7!Qdl%E0uL9Y@$BXNRclsZ{8D@vp>Ka2*3 z7xlYgn^hQ7Sdd?o*7>F4vALgeG+4AoO1F`r7sYE}v&XwcR~W?6_SpM^XmL^39?=iD zeVR}4LP?yc8iF{*iPZOcn8Cf<=$u803OUy922Rod+nC_H`&5XLUYr~e_-sR!v3;i` z36kwtTjn|Z9$FiTeGFT!zGOXw+1m&MJVllgc`F@hgU$E0si$C6wMwT+S1Ov2X4a;R zItCF6`9-+r;c;mbjE>^Toy^_=m_XcakteQi82$85;u-qN#YwVlVW8Y~jzw$_zx$j^ zLQJ!t!o(Ef!gyC~G()Oipl-0Hx6Y7BT=)|*kn{OFNJ51=l!WLah`+d!yYeA+B;dG- zJRDT?AetSOlEwW18V5g+*JGq%pfLY(aZyXzCCXO|>8GXW)XQ;2y2>KtBG=v~YWq$= zM|dzxJ3e)FZ^jfp_VPxUSTcdY-@g%OBYgSsi+ZUOLl1z7@oSfjWc?z9YV1pux2iTp z85jvQu@Q1u#W*Kwk=s{H*4I#7asFY$HRrA#WVg55k~!*kOeoFwjFIL~{Pr>a#e~x| zcD&D%i)gM*2`%4;?S`QpD~IeCys-?Ahdi&Ny0$0T_o!+X*IutJ+f(#)-m2$n@k(r- z!fD{{<#L=4=BKh>ecYeSFOFBQyTqU%-?rq)@VpQB##jb^Pdt3;T)D^`8J6wHS>5zd zKkz#{Trj(({&+wt?7a6^Zwa_tknu$sp=#l!qzxYzzZ9O(8TQk63V(M1(+sD%kEgrM z=fh)HIo%bQ7?zEkw)(xB=7*W7A4hXndq|c}vG3*T`rDC=#^!hRQ8M00!#FqR;>Ijv z>yct+$*)*ZQ-tSw*=A}c4~sy37N=#^`f1d=Ur+T-+Oqmk{LTyTP*5U`-EA`EvV; z7v%)2(aef%=Z$<){3AFbQH>z!I=@R7C#hAu`Ee#iWMe(L%RO!FM}ggn&>iw?PU}u` zfwN7+eEgx7>okK88N-sWsphgCja z-fAP%3`Hg+=}!eFhv`Z>y@C8aLWJ`^GTRS8l|E#3goneHcTSq5d+;tV`an-gxQieuc>KzTSQZ%LD(6Mu)UN)-%>dpNOK~!hs z(BaZr5H!m}h_V*c_D32~(R#aOlzk=l6 z6xL4188N;93H6d7xY{Z!?j_hzi_O??%sfkY96>*EH{zQU%vq{wTBfz3t&8D`QH9>t z!I{^^32P-x4Gp52rq@dTl}o5G_S%D2byC1~(snWnG0itb5UB1dxw~{aUS`w0&sdzN zWF(eoSYufVxok|$SuGEc6nnIgMd-K3%S;*r=fRU})0)CfqT{!AM+jXI^9{-~y(&>} z;<=_yacaLF6u%-62Rc#da#*_}IkkT)aQ1F6t>V;J_}$RC85@q+EmqXgI6xs4>r_#H z=!V{HR`Qji?3=sbl>0rzk?My)NGZDKesk6?+H8jmyrfB49>x6rM zfm=@Jkmx5U+y*Z3)`^jsHh!(|$(Xc}Q?a*l73d@AqY7dxC~y|6@ozGU*d^P>3^#F0 zeIOfjO%&cWSmxQEmxW+06sL<5nM$_=UuFB$b)BfRLB_UWU?e&UWBw_%uLRb|*t#LWTwNZ>Q2$=eJAofB{?|MJSpu(u7WA_A(S4Kh$Qi&F3>p;mVk<>PUT8E010&e)LPc5o8dx|VeGr-QvW!c!(h2H-- z#Ae09cFeJ%I(D)02OP~x{d3GTjcTn~+k>_J)9WqmO3})9`~*egY%2^jgieCv!h}(k zD+!Cf4soLlubT;?n=fSEkpX7r6Pr=1w`->Dl-$j?o*o?q-S{tSPd}x!xC`prinJTx za38;SF|CT%V6f()UXXe!W{y6mJ0FD0;UnC`GO1PL z>7M}K!0XpTiA;O#Vz_k-Us%4*kzJe>Y8TVWE}EvPEB)d_vdWsynK!YfyvVd1CA&s9 zIPtT1@6A3$(uhrJV$VivvL~_M8QgwC%c71}PG}SmOH9jjFZmRl-%Y+(bdDQl-gar5$4hQD;CaPJ4`l_x1FLMa8A+srWHvQ;wa$xa85?kXoB8Y|zD2JFD-=kp>R$)t z2w^&gSNghq>}_7;n6oL>}#;t_gVB6?R#Qo$mKHc|n}>E@{Uh{j<2AJ--KB%u##x(s$&mM9gq$hBdpk4~Xt! zd@oZHJzvOaaI^5|byLf@zZaz|lg8@0nr5%;EZ<%HF2_Bqk^f6_37eoZjDh`v2=1mQ5`?Lif>9E8HFOSIaBf(!O17c@- zH(tJ7Oj&bkb}tBQc|IPx8#dv~xrbinUY7*r_(%mHnhCr40G0R@2|*rUaiD8E)kvez zEO%0(t{&K8A|5`y!?YBNd}fem^59Ahx={*te_wuJWwDYcpxAku6B+l4El}$YbS>PS*@7< z&ygzCF}Z$5l$WGo*X_bYC51UXg(5v-2_1a1Ym_?t72I6k*agAuuYlXl9l6uAnbYBw z(=HJ#Z$I{`gwK*YK_W!0)Y_V6yKEa9l_R$8?jbRGe1_>w9PynbvSK&`Z}Cd~xG3WT z<pMA~PrbQDfOI^5zxy*o+EcwWL=^~GHp;j%bs*QNr7=#cK9YjL7 z2IS^r!$3rHE_o*$ZIZ7HCmSL29B!vQ<4-%M-@1{hkn0oSbiupBrus+3mSD%jJ)c0L!;1zaQ>eVUam!g$jhgK#H>O^01=XXN3GCOwqhrY{VY zr_=+PQR^M)1MRdRGjt_kG82XVE$2g&6A74J+SbS1k02uW>N@AmIOJ{#!S@Vr-3|J8 zwZBBxUvGUV{X`4`^6B4d1I+<80EWL_nf_FUKGV{+TVuy~qX}5o)wO;^o2TKZi8B;Pg$a{Wf%*DN{1@m0%XRPdjt6U!Qk@3gWz9$t^tfyU7sj*Nr zkn|fU>^i@g;|dKOs%OySkFIqqYAj zFLS&iiiy4lm`DL8v!kHcqdW$6S{5=FZr52~>k+6_oFNcb*}BQZOuSxYTO4dgdU~L& z_FL*=QKXtsUq^uUcJ!Y7^xR(4PArS=`zh zLsqLc97D;1E7H+ta${u`4+*bNO|Np_&)G4&yrb=`H+EWBa6I6;>IOZvAF|Kxc$^{6 zt1c(#yo~3qT1g=pgowp1y>Ne7H?B?K=?a=c=r8o~OcejZ*}q^Lr3R=E#=5Iki*6F+ z8H2|4LgM{^(xps!3PAC5Ge$`){Ydg&$nnD+B}+vG6Df+SwLs1D$yyw-btIs2HZW`28o zEm4o{n%mL*{C>4mqyP4}bTaNFx_B?I$M1c$9cuUXwigezUSOaULYN1`yhFjugd{;` ztMDBpbK2DyFBl~0b0?<(5fqhpAi<@A(1s2e0&5Gu%Bx#wgkv7Kp5{+w2*MCxZ#0P#Mp;c06Z5a6LHc)X8AH=>=+p`vRZ-xZbJr+m-O{`+_+Zfdmr3c%Q zv{P&!#nRjrRq3TI)JqCG&0&Y0`3b~A*D;7fN4_nB1U02dE;p=sJEN0FEXN@0k(gI4 z-pCH5k_EPIdyvMUSEx`Gfka2_HyFXO2myUr4QHkU>Jf;MQB-jp6aE3FF14c_h+=bt z;YRAdy3Klmxk811mBA+A_cb;ymcdsw8C{OJd`4gXN#F+Kysm4_1tV(_C|OK}PH` zvm~sjTKzT4t{R+8ea-9$&(VcN7{b^vDq2q~>1|^wO z7$ReJbz4Y4UP42k4Qp7-v--*)91``rTUd$ zDqHNH>7aSaH;>J%!nI-dd7Pq?Jn7dl`|ry$DrX1R{QDiTT<6C8nuOnNbt|uo2`tFV zz4lpzu?erpL)vB7`D9LCk2GXP{mDMXrY0zKjHf8ugF;?43c_OK`-`&aujU_WksWB^H>( zvasgD*1Tkqsag05wFsyznmEgd5jwPg^LeOkZYFefVbr`)K&-OrY}L=$*(9?tU8@YA zkWAN!Vx1{GMUe!dzCi^PZ$NkMptWS9q=78)lGwly>RSpkv`Cn+r2$5`3BwTR5V-?> zodv(TgDpX8>o0P>%X|W$3n+sfgz-c<+%PZO~!Ay@!#^MF0WF7G=#|;Y=3E zV5EXPDjnc{H4^uhdLwc)RN#aU&K@n`MS@EG8i8IP%Tel{ILUTBsmN)I3_IsX-LvVe z*g_?zkX91lbSLYIT-CXgT3-8<+vfRpXdpBPhTOuYv!P8-YhlYF1YgLa)iikH=cu{Q zrbBI7qgq*AVnNkZU}ZymPe$OwPZ>KBu?aebrYvxL)ZH2}yEw7qHW zQ5j_#_rWe99+v1Skv0)u2VDMQ1vS%94ZOUN%eo9bc13n4(O8p?nO_bjjoAaOf#||8 zAK<8ia{XD6QNz13BT1R7oqDF^r-s5jTX#dJp7EwfVEV5+u|}~@!=+Wd8od*Dy>#9~q)?GZ?VQXX9icV8Co3G)|lli~TpX-PI@H4r}^WnaEp1PwL?M{fi^o7}n*ff6MK(wQ*l?6USw$;`ZrQ zsA`4a_E#N?C4vp;B)G$<`nx(8wX^OMvVt@y7{Qp-e{Vhhe?Dmo+t~sEwm^LqcYA=7?w>2)!XKw&|1tWnt;fFv?4JY*j&}A=|BGezBMxh!(CzXM zzw3$j(M10r{+F=- zB>1BQO1hW*!H3<+5Bu1sxK!D@_ zYJfi-X~FYGeECD3DSQwRw7;1R{NVcwZR>1f1aP#lHT$RAfAEF=o>^%v>y2( z!(VuFfT79%VO=oEIVRyF;JOb_|1LTn*1ymITNB$4@16dW@eTS%afJ`jQ9lCsGdBLc z_4xn#{4cmGz(^SY1b%q`9~qLd$0A-o7)GK08NNR5zZgtE#*&Jmk@bIU_6a5wJ3mAa zkhWkDkPij^d+YI!`VjQ5@ST9}*8ki6KTS@Fw@yF-2?Emf(Odr>Jr~Wt!T*Qvf8Yb4 z&nADwYyJ@L?@brO_}{+U0so_VfAESZwqM-cKDsL`1@EKksM$=WzQ2|L1Mc zzhm$5f2jBu{QtNq`X|#rQ~dwWWLotvrhg{<3eu1t1MN?%V}PK1Na)e + + @@ -21,6 +23,10 @@ + + + diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index 5508c2b..13ff90c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -1,8 +1,11 @@ package com.hawhamburg.micr0bu import android.Manifest +import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.appcompat.app.AppCompatActivity import androidx.activity.SystemBarStyle @@ -46,6 +49,7 @@ import com.hawhamburg.micr0bu.ui.screens.MqttSettingsScreen import com.hawhamburg.micr0bu.ui.screens.SensorsSettingsScreen import com.hawhamburg.micr0bu.ui.screens.SettingsScreen import com.hawhamburg.micr0bu.ui.screens.UnitsSettingsScreen +import com.hawhamburg.micr0bu.ui.screens.UseCaseAlertsSettingsScreen import com.hawhamburg.micr0bu.ui.theme.MicrOBUTheme import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import com.hawhamburg.micr0bu.viewmodel.SensorViewModel @@ -74,7 +78,10 @@ class MainActivity : AppCompatActivity() { val activeTransport by mqttViewModel.activeTransport.collectAsState() val usbConnected by mqttViewModel.usbConnected.collectAsState() val obuStationTypeWarning by mqttViewModel.obuStationTypeWarning.collectAsState() + val obuStationType by mqttViewModel.obuStationType.collectAsState() val tripServiceState by tripViewModel.serviceState.collectAsState() + val showBatteryOptPrompt by tripViewModel.showBatteryOptPrompt.collectAsState() + val useCaseEnabledMap by mqttViewModel.useCaseEnabledMap.collectAsState() MicrOBUTheme(darkTheme = state.darkTheme) { val view = LocalView.current @@ -128,6 +135,7 @@ class MainActivity : AppCompatActivity() { activeTransport = activeTransport, usbCableConnected = usbConnected, obuStationTypeWarning = obuStationTypeWarning, + obuStationType = obuStationType, onNavigateToConnection = { navController.navigate(Screen.Connection.route) }, onNavigateToSensors = { navController.navigate(Screen.Sensors.route) { @@ -147,6 +155,7 @@ class MainActivity : AppCompatActivity() { state = state, mqttConnectionState = mqttConnectionState, tripServiceState = tripServiceState, + showBatteryOptPrompt = showBatteryOptPrompt, onToggleRecording = { // Start / stop both CSV recording and the event detection service viewModel.toggleRecording() @@ -155,6 +164,15 @@ class MainActivity : AppCompatActivity() { onOpenSessionLog = { navController.navigate(Screen.Log.route) }, + onOpenBatterySettings = { + startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + ) + tripViewModel.recheckBatteryOpt() + }, + onDismissBatteryOptPrompt = tripViewModel::dismissBatteryOptPrompt, ) } composable(Screen.Log.route) { @@ -205,10 +223,18 @@ class MainActivity : AppCompatActivity() { onOpenUnits = { navController.navigate(Screen.SettingsUnits.route) }, onOpenConnection = { navController.navigate(Screen.SettingsConnection.route) }, onOpenMqtt = { navController.navigate(Screen.SettingsMqtt.route) }, + onOpenUseCaseAlerts = { navController.navigate(Screen.SettingsUseCaseAlerts.route) }, onOpenDeveloper = { navController.navigate(Screen.SettingsDeveloper.route) }, onOpenAbout = { navController.navigate(Screen.SettingsAbout.route) }, ) } + composable(Screen.SettingsUseCaseAlerts.route) { + UseCaseAlertsSettingsScreen( + enabledMap = useCaseEnabledMap, + onToggle = mqttViewModel::setUseCaseEnabled, + onBack = { navController.popBackStack() }, + ) + } composable(Screen.SettingsConnection.route) { ConnectionSettingsScreen( mqttPrefs = mqttPrefs, diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt index dab3130..0d40acd 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/CsvExporter.kt @@ -2,8 +2,11 @@ package com.hawhamburg.micr0bu.data import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.content.FileProvider import com.hawhamburg.micr0bu.viewmodel.RecordingSession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -17,28 +20,34 @@ fun sessionFileName(session: RecordingSession): String = .replace(" ", "_").replace(":", "-")}.csv" /** - * Returns the full CSV content for a session. - * If stream-to-file data exists (v0.2.0+ recording) the real per-sample rows are returned. - * Falls back to a metadata-only summary for older sessions. + * Shares the session CSV via Android's share sheet. + * + * Streams the on-disk data file directly to the cache without loading it into memory — + * avoids OOM for long sessions (1 hr+ at 50 Hz can exceed 100 MB as a String). + * Must be called from a coroutine; file I/O runs on [Dispatchers.IO]. */ -fun readSessionCsvContent(context: Context, session: RecordingSession): String { - val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv") - return if (dataFile.exists()) dataFile.readText() else buildSessionCsv(session) -} +suspend fun shareSessionCsv(context: Context, session: RecordingSession) { + val fileName = sessionFileName(session) + val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv") + val cacheFile = File(context.cacheDir, fileName) -fun shareSessionCsv(context: Context, session: RecordingSession) { - val fileName = sessionFileName(session) - val content = readSessionCsvContent(context, session) - - // Write to cache so FileProvider can serve it via ACTION_SEND - val file = File(context.cacheDir, fileName).also { it.writeText(content) } + withContext(Dispatchers.IO) { + if (dataFile.exists()) { + dataFile.inputStream().buffered().use { input -> + cacheFile.outputStream().buffered().use { output -> + input.copyTo(output) + } + } + } else { + cacheFile.writeText(buildSessionCsv(session)) + } + } val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", - file, + cacheFile, ) - val intent = Intent(Intent.ACTION_SEND).apply { type = "text/csv" putExtra(Intent.EXTRA_STREAM, uri) @@ -48,6 +57,25 @@ fun shareSessionCsv(context: Context, session: RecordingSession) { context.startActivity(Intent.createChooser(intent, "Export session")) } +/** + * Streams the session CSV directly into a SAF [Uri] (from the "Save to device" picker). + * + * Same streaming approach as [shareSessionCsv] — no full-file String ever allocated. + * Must be called from a coroutine; file I/O runs on [Dispatchers.IO]. + */ +suspend fun saveSessionCsvToUri(context: Context, session: RecordingSession, uri: Uri) { + val dataFile = File(File(context.filesDir, "sessions"), "${session.id}.csv") + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(uri)?.buffered()?.use { output -> + if (dataFile.exists()) { + dataFile.inputStream().buffered().use { input -> input.copyTo(output) } + } else { + output.write(buildSessionCsv(session).toByteArray(Charsets.UTF_8)) + } + } + } +} + /** * Metadata-only CSV fallback — used for sessions recorded before stream-to-file was implemented, * and by [readSessionCsvContent] when no data file is present on disk. diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt deleted file mode 100644 index f06f98c..0000000 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/CamBuilder.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.hawhamburg.micr0bu.data.mqtt - -import com.hawhamburg.micr0bu.data.GnssReading -import com.hawhamburg.micr0bu.data.ImuReading -import org.json.JSONArray -import org.json.JSONObject -import kotlin.math.PI - -/** - * MQTT topic used to inject a CAM message into the OBU. - * - * TODO: Confirm this topic with consider it GmbH / HAW Hamburg before production use. - * The MQTT API v6 documentation only covers *received/processed* output topics - * (v2x-uca/output/json/…). No inbound CAM injection topic is currently documented. - * "v2x/tx/cam" is a reasonable candidate based on the naming convention; verify with - * the OBU firmware team before using in production. - */ -const val CAM_TX_TOPIC = "v2x/tx/cam" - -/** - * Builds a CAM JSON payload for test transmission. - * - * The schema mirrors the processed RX format on [v2x-uca/output/json/cam] per MQTT API v6 §2.2.3, - * adapted for outbound injection with phone sensor data. - */ -object CamBuilder { - - fun build( - gnss: GnssReading, - gyro: ImuReading?, - stationId: Long, - ): String { - // Gyroscope Z axis (rad/s) → yaw rate (deg/s) - val yawRateDps = gyro?.z?.let { it.toDouble() * (180.0 / PI) } ?: 0.0 - - return JSONObject().apply { - put("type", "v2x-cam") - put("stationId", stationId) - put("stationType", "cyclist") // ETSI stationType = 2 (cyclist) mapped to string label - put("position", JSONObject().apply { - put("type", "Point") - // GeoJSON coordinate order: [longitude, latitude, altitude] - put("coordinates", JSONArray().apply { - put(gnss.longitude) - put(gnss.latitude) - put(gnss.altitude) - }) - }) - put("heading_deg", gnss.bearingDeg.toDouble()) - put("speed_mps", gnss.speedMs.toDouble()) - put("yawRate_dps", yawRateDps) - put("vehicleRole", "default") - put("pathHistory", JSONArray()) - put("positionError_stdDev", gnss.accuracyM.toDouble()) - }.toString() - } -} diff --git a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt index 0a63766..8a7ca8c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/data/mqtt/MqttRepository.kt @@ -1,9 +1,9 @@ package com.hawhamburg.micr0bu.data.mqtt -import android.content.Context import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector -import dagger.hilt.android.qualifiers.ApplicationContext +import com.hawhamburg.micr0bu.domain.denm.DENM_CTRL_TOPIC +import com.hawhamburg.micr0bu.domain.denm.buildDenmPayload import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.coroutines.resumeWithException import kotlinx.coroutines.suspendCancellableCoroutine @@ -34,6 +35,8 @@ import javax.inject.Inject import javax.inject.Singleton import org.eclipse.paho.client.mqttv3.MqttMessage as PahoMqttMessage +private const val MAX_MESSAGES_PER_TOPIC = 50 + private val SUBSCRIBED_TOPICS = listOf( "sys/state/heartbeat", "sys/state/cellular", @@ -47,7 +50,6 @@ private val SUBSCRIBED_TOPICS = listOf( @Singleton class MqttRepository @Inject constructor( - @ApplicationContext private val context: Context, private val prefs: MqttPreferences, private val usbDetector: UsbNetworkDetector, ) { @@ -64,6 +66,12 @@ class MqttRepository @Inject constructor( ) val messages: SharedFlow = _messages.asSharedFlow() + // Per-topic message log, kept here (singleton) so it survives even when no screen is + // collecting — e.g. DENM TX messages emitted by TripRecordingService while the V2X + // Monitor screen isn't open. + private val _topicMessages = MutableStateFlow>>(emptyMap()) + val topicMessages: StateFlow>> = _topicMessages.asStateFlow() + /** Active transport derived from persisted prefs. */ val activeTransport: StateFlow = prefs.prefsFlow .map { p -> @@ -75,6 +83,115 @@ class MqttRepository @Inject constructor( @Volatile private var activeClient: MqttAsyncClient? = null private var connectJob: Job? = null + // ── DENM use case state ────────────────────────────────────────────────── + // Shared between MqttViewModel (manual trigger) and TripRecordingService + // (automatic event-driven triggers). Only one use case may be active at a time. + + private val _denmActive = MutableStateFlow(false) + val denmActive: StateFlow = _denmActive.asStateFlow() + + private val _lastDenmPayload = MutableStateFlow(null) + val lastDenmPayload: StateFlow = _lastDenmPayload.asStateFlow() + + private val _activeDenmUseCase = MutableStateFlow(null) + /** The use case id (e.g. "c2c-eebl") currently active on the OBU, if any. */ + val activeDenmUseCase: StateFlow = _activeDenmUseCase.asStateFlow() + + /** + * True if the last activate/deactivate couldn't reach the OBU because the link was down. + * Cleared once [resyncDenmOnReconnect] successfully republishes on the next connection. + */ + @Volatile private var pendingDenmResync = false + + init { + // Re-send the last DENM control message once the link comes back, so a use case + // triggered (or cleared) while the OBU was unreachable still takes effect. + scope.launch { + _connectionState.collect { state -> + if (state == MqttConnectionState.CONNECTED) resyncDenmOnReconnect() + } + } + } + + /** + * Activate [useCaseId] on the OBU. No-op if a use case is already active + * (per spec, only one DENM use case may be active at a time). + * Local state updates regardless of connectivity — recording/event-detection never + * blocks on the OBU link; if the link is down, the activation is resent on reconnect. + * Returns true if the activation was recorded. + */ + fun activateDenm(useCaseId: String): Boolean { + if (_denmActive.value) return false + val payload = buildDenmPayload(useCase = useCaseId, active = true) + _lastDenmPayload.value = payload + _denmActive.value = true + _activeDenmUseCase.value = useCaseId + sendDenmPayload(payload) + return true + } + + /** + * Deactivate whatever DENM use case is currently active. No-op if none is active. + * Same offline-safe behaviour as [activateDenm] — resent on reconnect if needed. + */ + fun deactivateDenm() { + if (!_denmActive.value) return + val useCaseId = _activeDenmUseCase.value ?: return + val payload = buildDenmPayload(useCase = useCaseId, active = false) + _lastDenmPayload.value = payload + _denmActive.value = false + _activeDenmUseCase.value = null + sendDenmPayload(payload) + } + + /** + * Publishes [payload] immediately if the OBU link is up. If not, marks the state for + * resync so it's republished as soon as the connection is restored — no DENM control + * message is silently dropped. + */ + private fun sendDenmPayload(payload: String) { + if (_connectionState.value == MqttConnectionState.CONNECTED) { + emitDenmTxMessage(payload) + publishRetained(DENM_CTRL_TOPIC, payload) + } else { + pendingDenmResync = true + } + } + + /** Republish the last DENM control message after reconnecting, if it never went out. */ + private fun resyncDenmOnReconnect() { + if (!pendingDenmResync) return + pendingDenmResync = false + val payload = _lastDenmPayload.value ?: return + emitDenmTxMessage(payload) + publishRetained(DENM_CTRL_TOPIC, payload) + } + + /** Inject a synthetic TX message into the message stream so it shows in the V2X Monitor. */ + private fun emitDenmTxMessage(payload: String) { + recordMessage( + MqttMessage( + topic = DENM_CTRL_TOPIC, + payload = payload, + timestamp = System.currentTimeMillis(), + direction = MessageDirection.TX, + ) + ) + } + + /** + * Record a message into both the live [messages] stream (for screens currently open) + * and the persistent [topicMessages] log (survives even when no screen is collecting). + */ + private fun recordMessage(msg: MqttMessage) { + _messages.tryEmit(msg) + _topicMessages.update { current -> + val updated = ((current[msg.topic] ?: emptyList()) + msg) + .takeLast(MAX_MESSAGES_PER_TOPIC) + current + (msg.topic to updated) + } + } + // ── Public API ──────────────────────────────────────────────────────────── fun connect() { @@ -93,22 +210,6 @@ class MqttRepository @Inject constructor( } } - /** - * Publish a message to [topic]. No-op when not connected. - * Uses QoS 0 (fire-and-forget) by default — suitable for test CAM frames. - */ - fun publish(topic: String, payload: String, qos: Int = 0) { - val client = activeClient ?: return - if (!client.isConnected) return - scope.launch { - runCatching { - val msg = PahoMqttMessage(payload.toByteArray(Charsets.UTF_8)) - msg.qos = qos - client.publish(topic, msg) - } - } - } - /** * Publish a retained message to [topic] at QoS 1. No-op when not connected. * The retain flag ensures the OBU's Use Case app receives it on (re)connect. @@ -185,7 +286,7 @@ class MqttRepository @Inject constructor( override fun connectionLost(cause: Throwable?) { lostSignal.complete(cause) } override fun messageArrived(topic: String, message: PahoMqttMessage) { - _messages.tryEmit( + recordMessage( MqttMessage( topic = topic, payload = message.payload.toString(Charsets.UTF_8), @@ -210,15 +311,15 @@ class MqttRepository @Inject constructor( // Bind socket to the USB tethering interface only when the cable is present. // Without this, traffic would be routed over Wi-Fi even in USB-C mode. - if (usbActive) { - socketFactory = usbNet!!.socketFactory + if (isUsbC && usbNet != null) { + socketFactory = usbNet.socketFactory } } // Connect (bridge callback → coroutine) suspendCancellableCoroutine { cont -> client.connect(options, null, object : IMqttActionListener { - override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {} + override fun onSuccess(token: IMqttToken) = cont.resumeWith(Result.success(Unit)) override fun onFailure(token: IMqttToken, ex: Throwable) = cont.resumeWithException(ex) }) @@ -230,7 +331,7 @@ class MqttRepository @Inject constructor( val qosArr = IntArray(topicsArr.size) { 0 } suspendCancellableCoroutine { cont -> client.subscribe(topicsArr, qosArr, null, object : IMqttActionListener { - override fun onSuccess(token: IMqttToken) = cont.resume(Unit) {} + override fun onSuccess(token: IMqttToken) = cont.resumeWith(Result.success(Unit)) override fun onFailure(token: IMqttToken, ex: Throwable) = cont.resumeWithException(ex) }) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt index 871ffe0..3d49393 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt @@ -46,6 +46,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { private var turningStartTime = 0L private var turningBothAgreed = false private var peakGyroTurning = 0.0 + private var turningEventFired = false // ── Stopping state ──────────────────────────────────────────────────────── @@ -169,12 +170,16 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { turningStartTime = timestamp peakGyroTurning = 0.0 turningBothAgreed = false + turningEventFired = false } turningFrames++ peakGyroTurning = maxOf(peakGyroTurning, gyroMagnitude) if (gyroAbove && speedAbove && bearingAbove) turningBothAgreed = true - if (turningFrames == config.turningSustainedFrames) { + // Emit once per continuous turn. Without turningEventFired, a single turn that + // stays above threshold for longer than turningSustainedFrames would re-trigger + // every turningSustainedFrames frames (e.g. a 2 s turn at 20 frames/event = 5 events). + if (turningFrames >= config.turningSustainedFrames && !turningEventFired) { val confidence = if (turningBothAgreed) Confidence.HIGH else Confidence.LOW _events.tryEmit( @@ -190,10 +195,11 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { durationMs = timestamp - turningStartTime, ) ) - turningFrames = 0 + turningEventFired = true } } else { - turningFrames = 0 + turningFrames = 0 + turningEventFired = false } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt index d1025a6..8b3bc98 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt @@ -5,6 +5,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.ServiceInfo import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -12,6 +13,7 @@ import android.hardware.SensorManager import android.location.Location import android.os.IBinder import android.os.Looper +import android.os.PowerManager import androidx.core.app.NotificationCompat import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationCallback @@ -26,6 +28,7 @@ import com.hawhamburg.micr0bu.data.db.AppDatabase import com.hawhamburg.micr0bu.domain.detection.DetectionConfig import com.hawhamburg.micr0bu.domain.detection.EventDetector import com.hawhamburg.micr0bu.domain.detection.EventType +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -44,7 +47,16 @@ import kotlin.math.sqrt * • Accelerometer + Gyroscope at [SensorManager.SENSOR_DELAY_GAME] (~50 Hz) * • FusedLocationProviderClient at 1 Hz * • Accel/gyro magnitudes + last GPS state → [EventDetector] → Room via [TripRepository] + * + * Per the requirements doc (Section 11), Phase A is independent of OBU connectivity — no + * cable, no MQTT broker, no hardware required — so this service has no MQTT/DENM dependency. + * Detected events are recorded for trip history/CSV only; a future phase will feed them into + * the CAM-based Use Case Detection Engine (`com.hawhamburg.micr0bu.domain.usecase`) as + * corroborating signals (Section 11.4). No DENM is triggered by detected events — the only + * way to send a DENM is the manual "Send Test DENM" button in the V2X Monitor screen, kept + * solely to verify the OBU↔RSU antenna link independent of any detected event. */ +@AndroidEntryPoint class TripRecordingService : Service() { // ── Constants ───────────────────────────────────────────────────────────── @@ -66,6 +78,11 @@ class TripRecordingService : Service() { private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob) private var timerJob: Job? = null + // Held only while a trip is actively recording — keeps the CPU alive for sensor + // and GPS callbacks when the screen is off. Released immediately on stop so the + // phone can sleep normally when the app is idle. + private var wakeLock: PowerManager.WakeLock? = null + // ── Dependencies ────────────────────────────────────────────────────────── private lateinit var sensorManager: SensorManager @@ -207,6 +224,14 @@ class TripRecordingService : Service() { // ── Trip control ───────────────────────────────────────────────────────── private fun startTrip() { + // Keep the CPU awake for the duration of the trip so sensor and GPS callbacks + // are delivered reliably when the screen is off. Released in stopTrip(). + val pm = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "micr0bu:TripRecording", + ).also { it.acquire() } + detector.reset() brakingCount = 0 turningCount = 0 @@ -272,11 +297,21 @@ class TripRecordingService : Service() { registerSensors() requestLocationUpdates() - // Start foreground ASAP (within 5 s required by Android) - startForeground(NOTIFICATION_ID, buildNotification(0L, 0)) + // Start foreground ASAP (within 5 s required by Android). + // Specify FOREGROUND_SERVICE_TYPE_LOCATION so Android 10+ knows why we need + // continuous location access, and so the notification is re-posted automatically + // if the user dismisses it (Android 13+ allows users to swipe foreground + // service notifications away, but the system re-shows them after ~1 s). + startForeground( + NOTIFICATION_ID, + buildNotification(0L), + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION, + ) } private fun stopTrip() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null timerJob?.cancel() unregisterSensors() removeLocationUpdates() @@ -377,7 +412,7 @@ class TripRecordingService : Service() { .createNotificationChannel(channel) } - private fun buildNotification(elapsedSeconds: Long, eventCount: Int) = + private fun buildNotification(elapsedSeconds: Long) = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Recording trip") .setContentText( @@ -390,17 +425,22 @@ class TripRecordingService : Service() { .setContentIntent( PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE, + Intent(this, MainActivity::class.java).apply { + // Bring the existing activity to the front rather than + // launching a second instance on top of the running one. + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) ) + .setAutoCancel(false) .build() private fun updateNotification() { val elapsed = TripServiceBus.state.value.elapsedSeconds - val total = brakingCount + turningCount + stoppingCount val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - nm.notify(NOTIFICATION_ID, buildNotification(elapsed, total)) + nm.notify(NOTIFICATION_ID, buildNotification(elapsed)) } private fun formatElapsed(seconds: Long): String { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt index f2b7201..d86afef 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/navigation/AppNavigation.kt @@ -47,6 +47,7 @@ sealed class Screen(val route: String, val labelRes: Int) { data object SettingsUnits : Screen("settings/units", R.string.settings_units) data object SettingsConnection : Screen("settings/connection", R.string.settings_connection) data object SettingsMqtt : Screen("settings/mqtt", R.string.settings_mqtt_section) + data object SettingsUseCaseAlerts : Screen("settings/usecases", R.string.settings_usecase_alerts) data object SettingsDeveloper : Screen("settings/developer", R.string.settings_developer) data object SettingsAbout : Screen("settings/about", R.string.settings_about) } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt index 57468ba..d0c8525 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/DashboardScreen.kt @@ -61,6 +61,7 @@ fun DashboardScreen( activeTransport: TransportType = TransportType.USB_C, usbCableConnected: Boolean = false, obuStationTypeWarning: Boolean = false, + obuStationType: Int? = null, onNavigateToConnection: () -> Unit, onNavigateToSensors: () -> Unit, onNavigateToMap: () -> Unit, @@ -195,7 +196,10 @@ fun DashboardScreen( modifier = Modifier.size(16.dp), ) Text( - stringResource(R.string.dash_station_type_warning), + buildString { + append(stringResource(R.string.dash_station_type_warning)) + if (obuStationType != null) append(" (got $obuStationType)") + }, style = MaterialTheme.typography.bodySmall, color = Color(0xFFFFC107), ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt index 294b3a8..cae6f7c 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/MqttTopicViewerScreen.kt @@ -65,6 +65,11 @@ import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.data.mqtt.MessageDirection import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttMessage +import com.hawhamburg.micr0bu.domain.cam.CamParser +import com.hawhamburg.micr0bu.domain.denm.DenmUseCase +import com.hawhamburg.micr0bu.domain.usecase.AlertLevel +import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert +import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.viewmodel.MqttViewModel import org.json.JSONArray import org.json.JSONException @@ -86,6 +91,12 @@ private val DisconnectedGrayBg = Color(0xFF1C2733) private val DenmRed = Color(0xFFE53935) private val DenmRedBg = Color(0xFF3A0A0A) private val DenmAmber = Color(0xFFFFC107) +private val InfoBlue = Color(0xFF64B5F6) +private val InfoBlueBg = Color(0xFF10243A) +private val AwarenessAmber = Color(0xFFFFC107) +private val AwarenessAmberBg = Color(0xFF3A2D00) +private val WarningRed = Color(0xFFE53935) +private val WarningRedBg = Color(0xFF3A0A0A) @Composable fun MqttTopicViewerScreen( @@ -97,6 +108,9 @@ fun MqttTopicViewerScreen( val autoScroll by viewModel.autoScroll.collectAsState() val denmActive by viewModel.denmActive.collectAsState() val lastDenmPayload by viewModel.lastDenmPayload.collectAsState() + val activeDenmUseCase by viewModel.activeDenmUseCase.collectAsState() + val useCaseAlerts by viewModel.useCaseAlerts.collectAsState() + val ownStationId by viewModel.ownStationId.collectAsState() // Sort: sys/ topics first (heartbeat/health), then alphabetical val sortedTopics = topicMessages.keys.sortedWith( @@ -172,14 +186,17 @@ fun MqttTopicViewerScreen( connectionState = connectionState, denmActive = denmActive, lastDenmPayload = lastDenmPayload, + activeDenmUseCase = activeDenmUseCase, + useCaseAlerts = useCaseAlerts, onSelectTopic = { viewModel.selectTopic(it) }, onSendDenm = { viewModel.sendDenm() }, onStopDenm = { viewModel.stopDenm() }, ) } else { MessageDetailPane( - messages = topicMessages[selectedTopic] ?: emptyList(), - autoScroll = autoScroll, + messages = topicMessages[selectedTopic] ?: emptyList(), + autoScroll = autoScroll, + ownStationId = ownStationId, ) } } @@ -194,6 +211,8 @@ private fun TopicListPane( connectionState: MqttConnectionState, denmActive: Boolean, lastDenmPayload: String?, + activeDenmUseCase: String?, + useCaseAlerts: List, onSelectTopic: (String) -> Unit, onSendDenm: () -> Unit, onStopDenm: () -> Unit, @@ -202,11 +221,17 @@ private fun TopicListPane( Column(modifier = Modifier.fillMaxSize()) { - // ── DENM TX Control card ──────────────────────────────────────────── + // ── CAM-based Use Case Alert panel (Section 10.2 / 10.4) ───────────── + UseCaseAlertPanel(alerts = useCaseAlerts, isConnected = isConnected) + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) + + // ── DENM TX Control card — manual/test trigger only, not use-case-driven ── DenmTxCard( isConnected = isConnected, denmActive = denmActive, lastDenmPayload = lastDenmPayload, + activeDenmUseCase = activeDenmUseCase, onSendDenm = onSendDenm, onStopDenm = onStopDenm, ) @@ -310,6 +335,7 @@ private fun TopicRow( private fun MessageDetailPane( messages: List, autoScroll: Boolean, + ownStationId: Long?, ) { if (messages.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -331,18 +357,29 @@ private fun MessageDetailPane( LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { itemsIndexed(reversed, key = { index, msg -> "${msg.topic}-${msg.timestamp}-$index" }) { _, msg -> - MessageRow(msg) + MessageRow(msg, ownStationId) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) } } } @Composable -private fun MessageRow(msg: MqttMessage) { - // Show TX badge for all outbound messages; RX badge only on DENM topics - // (to avoid noisy badges on high-frequency CAM/SPAT traffic). - val showBadge = msg.direction == MessageDirection.TX || +private fun MessageRow(msg: MqttMessage, ownStationId: Long?) { + // CAM messages get an OWN/REMOTE badge instead of a TX/RX badge — that's the + // distinction the V2X Monitor and Use Case Alert panel actually care about (Section 10.4). + val isCamTopic = msg.topic.substringAfterLast('/') == "cam" + val camIsOwn: Boolean? = if (isCamTopic) { + remember(msg.payload, ownStationId) { + CamParser.parse(msg.payload, ownStationId, msg.timestamp)?.isOwn + } + } else null + + // For non-CAM topics: show TX badge for all outbound messages; RX badge only on DENM + // topics (to avoid noisy badges on high-frequency SPAT/MAP/CPM traffic). + val showDirectionBadge = camIsOwn == null && ( + msg.direction == MessageDirection.TX || (msg.direction == MessageDirection.RX && msg.topic.contains("denm", ignoreCase = true)) + ) Column( modifier = Modifier @@ -359,7 +396,8 @@ private fun MessageRow(msg: MqttMessage) { color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, ) - if (showBadge) DirectionBadge(msg.direction) + if (camIsOwn != null) OwnRemoteBadge(isOwn = camIsOwn) + else if (showDirectionBadge) DirectionBadge(msg.direction) } Spacer(Modifier.height(4.dp)) Box( @@ -389,6 +427,7 @@ private fun DenmTxCard( isConnected: Boolean, denmActive: Boolean, lastDenmPayload: String?, + activeDenmUseCase: String?, onSendDenm: () -> Unit, onStopDenm: () -> Unit, ) { @@ -450,7 +489,7 @@ private fun DenmTxCard( shape = RoundedCornerShape(4.dp), ) { Text( - "hln-sv", + activeDenmUseCase ?: DenmUseCase.STATIONARY.id, style = MaterialTheme.typography.labelSmall, color = if (denmActive) DenmRed else MaterialTheme.colorScheme.onSurfaceVariant, @@ -587,6 +626,129 @@ private fun DirectionBadge(direction: MessageDirection) { } } +// ── OWN / REMOTE badge (CAM messages) ───────────────────────────────────────── + +@Composable +private fun OwnRemoteBadge(isOwn: Boolean) { + val (text, fg, bg) = if (isOwn) { + Triple("OWN", ConnectedGreen, ConnectedGreenBg) + } else { + Triple("REMOTE", DenmAmber, Color(0xFF3A2D00)) + } + Surface(color = bg, shape = RoundedCornerShape(3.dp)) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = fg, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + ) + } +} + +// ── CAM-based Use Case Alert panel ──────────────────────────────────────────── + +@Composable +private fun UseCaseAlertPanel(alerts: List, isConnected: Boolean) { + val topAlert = alerts.firstOrNull() + val cardBg = when (topAlert?.alertLevel) { + AlertLevel.WARNING -> WarningRedBg + AlertLevel.AWARENESS -> AwarenessAmberBg + AlertLevel.INFO -> InfoBlueBg + null -> MaterialTheme.colorScheme.surfaceVariant + } + + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = cardBg), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + stringResource(R.string.mqtt_usecase_panel_title), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(8.dp)) + + when { + !isConnected -> Text( + stringResource(R.string.mqtt_usecase_not_connected), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + alerts.isEmpty() -> Text( + stringResource(R.string.mqtt_usecase_none_active), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + alerts.forEach { alert -> UseCaseAlertRow(alert) } + } + } + } + } +} + +/** Maps a use case to its human-readable narrative string resource (Section 10.4). */ +private fun UseCaseType.narrativeStringRes(): Int = when (this) { + UseCaseType.IMA_B -> R.string.usecase_narrative_ima_b + UseCaseType.IMA_S -> R.string.usecase_narrative_ima_s + UseCaseType.RTW_B -> R.string.usecase_narrative_rtw_b + UseCaseType.LTW_B -> R.string.usecase_narrative_ltw_b + UseCaseType.SMVA_BCW_B -> R.string.usecase_narrative_smva_bcw_b +} + +@Composable +private fun UseCaseAlertRow(alert: UseCaseAlert) { + val (fg, bg) = when (alert.alertLevel) { + AlertLevel.WARNING -> WarningRed to WarningRedBg + AlertLevel.AWARENESS -> AwarenessAmber to AwarenessAmberBg + AlertLevel.INFO -> InfoBlue to InfoBlueBg + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface(color = bg, shape = RoundedCornerShape(4.dp)) { + Text( + text = alert.alertLevel.name, + style = MaterialTheme.typography.labelSmall, + color = fg, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + // Primary, human-readable line — what the rider actually needs to read at a glance. + Text( + text = stringResource(alert.useCase.narrativeStringRes(), alert.timeToConflictSec), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + // Secondary technical line — use case ID + the raw numbers + which signal fired + // (yaw-rate / heading-trend / static-heuristic / distance-trend), so the detection + // logic stays verifiable rather than a black box. + Text( + text = "${alert.useCase.label} · " + stringResource( + R.string.mqtt_usecase_detail, + alert.remoteStationId, + alert.distanceMeters, + alert.closingSpeedMps, + alert.timeToConflictSec, + alert.signalNote, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + // ── Connection status chip ──────────────────────────────────────────────────── @Composable diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt index 56463e1..2546163 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.material.icons.filled.TableChart import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ButtonDefaults @@ -25,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,10 +46,33 @@ fun RecordingScreen( state: SensorUiState, mqttConnectionState: MqttConnectionState, tripServiceState: TripServiceBus.State, + showBatteryOptPrompt: Boolean, onToggleRecording: () -> Unit, onOpenSessionLog: () -> Unit, + onOpenBatterySettings: () -> Unit, + onDismissBatteryOptPrompt: () -> Unit, modifier: Modifier = Modifier, ) { + if (showBatteryOptPrompt) { + AlertDialog( + onDismissRequest = {}, // force explicit choice + title = { Text("Allow background GPS") }, + text = { + Text( + "To keep GPS running when the screen turns off during a ride, " + + "exempt MicrOBU from battery optimisation. " + + "The app will only use this during active recording." + ) + }, + confirmButton = { + Button(onClick = onOpenBatterySettings) { Text("Open settings") } + }, + dismissButton = { + TextButton(onClick = onDismissBatteryOptPrompt) { Text("Don't show again") } + }, + ) + } + Column( modifier = modifier .fillMaxSize() diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt index 18403a1..e768eba 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SessionLogScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,9 +39,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.hawhamburg.micr0bu.R -import com.hawhamburg.micr0bu.data.readSessionCsvContent +import com.hawhamburg.micr0bu.data.saveSessionCsvToUri import com.hawhamburg.micr0bu.data.sessionFileName import com.hawhamburg.micr0bu.data.shareSessionCsv +import kotlinx.coroutines.launch import com.hawhamburg.micr0bu.viewmodel.RecordingSession import com.hawhamburg.micr0bu.viewmodel.SensorUiState import java.text.SimpleDateFormat @@ -56,6 +58,7 @@ fun SessionLogScreen( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val scope = rememberCoroutineScope() var pendingDelete by remember { mutableStateOf(null) } var pendingSave by remember { mutableStateOf(null) } @@ -64,8 +67,8 @@ fun SessionLogScreen( ) { uri -> uri ?: return@rememberLauncherForActivityResult val session = pendingSave ?: return@rememberLauncherForActivityResult - context.contentResolver.openOutputStream(uri)?.use { it.write(readSessionCsvContent(context, session).toByteArray(Charsets.UTF_8)) } pendingSave = null + scope.launch { saveSessionCsvToUri(context, session, uri) } } pendingDelete?.let { session -> @@ -103,7 +106,7 @@ fun SessionLogScreen( SessionCard( session = session, index = state.sessions.size - index, - onShare = { shareSessionCsv(context, session) }, + onShare = { scope.launch { shareSessionCsv(context, session) } }, onSaveToDevice = { pendingSave = session; saveLauncher.launch(sessionFileName(session)) }, onDelete = { pendingDelete = session }, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt index 84fc73f..7952d4a 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/SettingsScreen.kt @@ -47,6 +47,8 @@ import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs +import com.hawhamburg.micr0bu.domain.usecase.UseCaseDetectionConfig +import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import com.hawhamburg.micr0bu.viewmodel.SensorUiState private data class LangOption(val tag: String, val flag: String, val labelRes: Int) @@ -66,6 +68,7 @@ fun SettingsScreen( onOpenUnits: () -> Unit, onOpenConnection: () -> Unit, onOpenMqtt: () -> Unit, + onOpenUseCaseAlerts: () -> Unit, onOpenDeveloper: () -> Unit, onOpenAbout: () -> Unit, modifier: Modifier = Modifier, @@ -99,6 +102,8 @@ fun SettingsScreen( RowDivider() MenuRow(stringResource(R.string.settings_mqtt_section), onOpenMqtt) RowDivider() + MenuRow(stringResource(R.string.settings_usecase_alerts), onOpenUseCaseAlerts) + RowDivider() MenuRow(stringResource(R.string.settings_developer), onOpenDeveloper) RowDivider() MenuRow(stringResource(R.string.settings_about), onOpenAbout) @@ -284,7 +289,57 @@ fun MqttSettingsScreen( ) { SubScreen(stringResource(R.string.settings_mqtt_section), onBack) { SectionCard { - MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange) + MqttSettingsSection(prefs = mqttPrefs, onChange = onMqttPrefsChange, onSave = onBack) + } + } +} + +@Composable +fun UseCaseAlertsSettingsScreen( + enabledMap: Map, + onToggle: (UseCaseType, Boolean) -> Unit, + onBack: () -> Unit, +) { + val config = remember { UseCaseDetectionConfig() } + + SubScreen(stringResource(R.string.settings_usecase_alerts), onBack) { + Text( + stringResource(R.string.settings_usecase_alerts_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + SectionCard { + UseCaseType.entries.forEachIndexed { index, type -> + SettingToggleRow( + label = "${type.label} · ${type.c2cId}", + checked = enabledMap[type] ?: true, + onCheckedChange = { onToggle(type, it) }, + ) + if (index != UseCaseType.entries.lastIndex) Divider() + } + } + + Text( + stringResource(R.string.settings_usecase_alert_levels_title), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SectionCard { + DisabledRow( + stringResource(R.string.settings_usecase_level_warning), + stringResource(R.string.settings_usecase_ttc_le, config.ttcWarningSec), + ) + Divider() + DisabledRow( + stringResource(R.string.settings_usecase_level_awareness), + stringResource(R.string.settings_usecase_ttc_le, config.ttcAwarenessSec), + ) + Divider() + DisabledRow( + stringResource(R.string.settings_usecase_level_info), + stringResource(R.string.settings_usecase_ttc_le, config.ttcInfoSec), + ) } } } @@ -334,7 +389,11 @@ private fun SectionCard(content: @Composable () -> Unit) { } @Composable -private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit) { +private fun MqttSettingsSection( + prefs: MqttPrefs, + onChange: (MqttPrefs) -> Unit, + onSave: () -> Unit = {}, +) { // Key each state to the corresponding saved pref so fields re-sync after an // external save (e.g. via the Save button), while preserving in-progress edits. var ip by remember(prefs.brokerIp) { mutableStateOf(prefs.brokerIp) } @@ -401,6 +460,7 @@ private fun MqttSettingsSection(prefs: MqttPrefs, onChange: (MqttPrefs) -> Unit) password = password, ) ) + onSave() }, modifier = Modifier.fillMaxWidth(), ) { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt index 8fc1feb..badfff5 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/MqttViewModel.kt @@ -2,7 +2,7 @@ package com.hawhamburg.micr0bu.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.hawhamburg.micr0bu.data.mqtt.MessageDirection +import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository import com.hawhamburg.micr0bu.data.mqtt.MqttConnectionState import com.hawhamburg.micr0bu.data.mqtt.MqttMessage import com.hawhamburg.micr0bu.data.mqtt.MqttPreferences @@ -10,6 +10,9 @@ import com.hawhamburg.micr0bu.data.mqtt.MqttPrefs import com.hawhamburg.micr0bu.data.mqtt.MqttRepository import com.hawhamburg.micr0bu.data.transport.TransportType import com.hawhamburg.micr0bu.data.transport.UsbNetworkDetector +import com.hawhamburg.micr0bu.domain.denm.DenmUseCase +import com.hawhamburg.micr0bu.domain.usecase.UseCaseAlert +import com.hawhamburg.micr0bu.domain.usecase.UseCaseType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -17,29 +20,23 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.json.JSONObject import javax.inject.Inject -private const val MAX_MESSAGES_PER_TOPIC = 50 - -private const val DENM_CTRL_TOPIC = "v2x-uca/input/denmtrg" -private const val DENM_USE_CASE = "hln-sv" - @HiltViewModel class MqttViewModel @Inject constructor( private val repo: MqttRepository, private val prefs: MqttPreferences, private val usbDetector: UsbNetworkDetector, + private val camUseCaseRepository: CamUseCaseRepository, ) : ViewModel() { // ── MQTT connection & messages ──────────────────────────────────────────── val connectionState: StateFlow = repo.connectionState - private val _topicMessages = MutableStateFlow>>(emptyMap()) - val topicMessages: StateFlow>> = _topicMessages.asStateFlow() + val topicMessages: StateFlow>> = repo.topicMessages private val _selectedTopic = MutableStateFlow(null) val selectedTopic: StateFlow = _selectedTopic.asStateFlow() @@ -69,11 +66,16 @@ class MqttViewModel @Inject constructor( // ── OBU identity (parsed from v2x/rx/obu_gnss own_info) ────────────────── - private val _obuStationType = MutableStateFlow(null) /** * stationType from the OBU's own_info (v2x/rx/obu_gnss). Should be 2 (cyclist/VRU) per * ETSI EN 302 637-2 Table 1. Null until the first obu_gnss message arrives. */ + private val _obuStationType = MutableStateFlow(null) + + /** + * The raw stationType value last reported by the OBU. Null until the first + * obu_gnss message arrives. Exposed so the UI can show the actual value. + */ val obuStationType: StateFlow = _obuStationType.asStateFlow() /** @@ -87,30 +89,42 @@ class MqttViewModel @Inject constructor( // ── DENM transmission ───────────────────────────────────────────────────── - private val _denmActive = MutableStateFlow(false) - /** True while the DENM use case is actively broadcasting on the OBU. */ - val denmActive: StateFlow = _denmActive.asStateFlow() + /** True while a DENM use case is actively broadcasting on the OBU. */ + val denmActive: StateFlow = repo.denmActive - private val _lastDenmPayload = MutableStateFlow(null) /** JSON string of the most recently transmitted DENM control message. */ - val lastDenmPayload: StateFlow = _lastDenmPayload.asStateFlow() + val lastDenmPayload: StateFlow = repo.lastDenmPayload - /** Tracks which use case is currently active so Stop uses the same usecase field. */ - private var activeDenmUseCase: String? = null + /** The use case id currently active on the OBU, if any (e.g. for showing in the UI). */ + val activeDenmUseCase: StateFlow = repo.activeDenmUseCase + + // ── CAM-based Use Case Detection (Section 10.2 / 10.4) ──────────────────── + // Entirely separate from the DENM transmission above: this consumes CAM only, raises + // local HMI alerts, and never triggers an outbound V2X message. + + /** The ego OBU's own station ID, learned from v2x/rx/obu_gnss's own_info. */ + val ownStationId: StateFlow = camUseCaseRepository.ownStationId + + /** Active CAM-based use case alerts, filtered to the use cases enabled in Settings. */ + val useCaseAlerts: StateFlow> = camUseCaseRepository.enabledAlerts + + /** Per-use-case enable/disable state (Settings > Use Case Alerts). */ + val useCaseEnabledMap: StateFlow> = camUseCaseRepository.enabledMap + + /** True if [stationId] is the ego OBU's own — used for OWN/REMOTE badges in the raw message list. */ + fun isOwnStationId(stationId: Long): Boolean = camUseCaseRepository.isOwnStationId(stationId) + + fun setUseCaseEnabled(type: UseCaseType, enabled: Boolean) { + camUseCaseRepository.setUseCaseEnabled(type, enabled) + } // ── Init ────────────────────────────────────────────────────────────────── init { viewModelScope.launch { + // Per-topic message lists now live in MqttRepository (survives screen close); + // here we just watch for own_info to track the OBU's reported stationType. repo.messages.collect { msg -> - // Route into per-topic message lists - _topicMessages.update { current -> - val updated = ((current[msg.topic] ?: emptyList()) + msg) - .takeLast(MAX_MESSAGES_PER_TOPIC) - current + (msg.topic to updated) - } - - // Check stationType from own_info — must be 2 (cyclist/VRU) if (msg.topic == "v2x/rx/obu_gnss") { runCatching { val stType = JSONObject(msg.payload) @@ -139,52 +153,19 @@ class MqttViewModel @Inject constructor( /** * Publish a uca-denmctrl activate message with the retain flag so the OBU's Use Case app - * receives it on any (re)connect. Only one use case may be active at a time. + * receives it on any (re)connect. Only one use case may be active at a time + * (no-op if another use case, manual or automatic, is already active). */ - fun sendDenm(useCase: String = DENM_USE_CASE) { - if (_denmActive.value) return // prevent concurrent activations - val payload = buildDenmPayload(useCase = useCase, active = true) - _lastDenmPayload.value = payload - _denmActive.value = true - activeDenmUseCase = useCase - emitTxMessage(DENM_CTRL_TOPIC, payload) - repo.publishRetained(DENM_CTRL_TOPIC, payload) + fun sendDenm(useCase: String = DenmUseCase.STATIONARY.id) { + repo.activateDenm(useCase) } /** - * Publish a uca-denmctrl deactivate message (retained). - * Uses the same usecase string as the activate call so the OBU phases out the correct DENM. + * Publish a uca-denmctrl deactivate message (retained) for whichever use case is + * currently active. */ fun stopDenm() { - if (!_denmActive.value) return - val useCase = activeDenmUseCase ?: DENM_USE_CASE - val payload = buildDenmPayload(useCase = useCase, active = false) - _lastDenmPayload.value = payload - _denmActive.value = false - activeDenmUseCase = null - emitTxMessage(DENM_CTRL_TOPIC, payload) - repo.publishRetained(DENM_CTRL_TOPIC, payload) - } - - private fun buildDenmPayload(useCase: String, active: Boolean): String = - """{"type":"uca-denmctrl","active":$active,"usecase":"$useCase","params":{}}""" - - /** - * Inject a synthetic TX [MqttMessage] into the local topic map so outbound messages - * appear in the V2X Monitor alongside received traffic. - */ - private fun emitTxMessage(topic: String, payload: String) { - val msg = MqttMessage( - topic = topic, - payload = payload, - timestamp = System.currentTimeMillis(), - direction = MessageDirection.TX, - ) - _topicMessages.update { current -> - val updated = ((current[topic] ?: emptyList()) + msg) - .takeLast(MAX_MESSAGES_PER_TOPIC) - current + (topic to updated) - } + repo.deactivateDenm() } // ── Lifecycle ───────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt index 0152888..59ddea8 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt @@ -1,7 +1,9 @@ package com.hawhamburg.micr0bu.viewmodel import android.app.Application +import android.content.Context import android.content.Intent +import android.os.PowerManager import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.hawhamburg.micr0bu.data.TripRepository @@ -27,6 +29,32 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic private val repository = TripRepository(AppDatabase.getInstance(application)) + // ── Battery optimisation prompt ─────────────────────────────────────────── + // Shown once on the Recording screen to ask the user to exempt the app so + // GPS callbacks are not throttled when the screen turns off during a trip. + // "Don't show again" persists across sessions via SharedPreferences. + + private val appPrefs = application.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + private val pm = application.getSystemService(Context.POWER_SERVICE) as PowerManager + + private val _showBatteryOptPrompt = MutableStateFlow(shouldShowBatteryPrompt()) + val showBatteryOptPrompt: StateFlow = _showBatteryOptPrompt.asStateFlow() + + private fun shouldShowBatteryPrompt(): Boolean = + !pm.isIgnoringBatteryOptimizations(getApplication().packageName) && + !appPrefs.getBoolean("battery_opt_dismissed", false) + + /** Called when user taps "Don't show again" — suppresses the prompt permanently. */ + fun dismissBatteryOptPrompt() { + appPrefs.edit().putBoolean("battery_opt_dismissed", true).apply() + _showBatteryOptPrompt.value = false + } + + /** Re-checks the system state — call after returning from battery settings. */ + fun recheckBatteryOpt() { + _showBatteryOptPrompt.value = shouldShowBatteryPrompt() + } + // ── Service state (live during a recording) ─────────────────────────────── /** Live recording state forwarded from [TripServiceBus]. */ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 17d616c..a68aeca 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -165,18 +165,40 @@ Mit der OBU verbinden und auf V2X-Verkehr warten Noch keine Nachrichten zu diesem Thema - + Zuletzt gesendete Nutzlast: - DENM-Übertragung + DENM-Übertragung (manueller Test) Test-DENM senden DENM stoppen Liegengebliebenes Fahrzeug (causeCode 94) Mit OBU verbinden, um DENM-Auslösung zu aktivieren DENM aktiv — OBU sendet über ITS-G5 - Sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg + Manueller Antennen-/RSU-Reichweitentest — sendet uca-denmctrl (retained) an v2x-uca/input/denmtrg. Wird nicht durch erkannte Ereignisse oder Use-Case-Alarme ausgelöst. Letztes TX anzeigen Ausblenden + + CAM-Use-Case-Alarme + Mit OBU verbinden, um CAM-basierte Use Cases auszuwerten + Keine aktiven Use-Case-Alarme + Station %1$d · %2$.0f m · Annäherung %3$.1f m/s · TTC %4$.1f s · %5$s + + + Kreuzendes Fahrzeug — %1$.0f s bis Konflikt + Stehendes Fahrzeug könnte losfahren — %1$.0f s + Auto biegt rechts auf dich zu — %1$.0f s + Auto biegt links auf dich zu — %1$.0f s + Schnell nahendes Fahrzeug — %1$.0f s + + + Use Case Alerts + Ein-/Ausschalten pro Use Case für das CAM-basierte Use-Case-Alarm-Panel im V2X-Monitor. Alle Use Cases sind CAM-only — keiner löst ein DENM aus. + Alarmstufen-Schwellenwerte (nur lesend) + Warning + Awareness + Info + ≤ %1$.0f s bis Konflikt + USB-C WLAN diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d85e526..9d7d671 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -166,18 +166,40 @@ Connect to the OBU and wait for V2X traffic No messages on this topic yet - + Last transmitted payload: - DENM Transmission + DENM Transmission (Manual Test) Send Test DENM Stop DENM Aftermarket Stationary Vehicle (causeCode 94) Connect to the OBU to enable DENM triggering DENM active — OBU broadcasting via ITS-G5 - Publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg + Manual antenna/RSU range test — publishes uca-denmctrl (retained) to v2x-uca/input/denmtrg. Not triggered by detected events or use case alerts. Show last TX Hide + + CAM Use Case Alerts + Connect to the OBU to evaluate CAM-based use cases + No active use case alerts + Station %1$d · %2$.0f m · closing %3$.1f m/s · TTC %4$.1f s · %5$s + + + Crossing vehicle ahead — %1$.0f s to conflict + Stopped vehicle may pull out — %1$.0f s + Car turning right toward you — %1$.0f s + Car turning left toward you — %1$.0f s + Fast-closing vehicle nearby — %1$.0f s + + + Use Case Alerts + Per-use-case enable/disable for the CAM-based Use Case Alert panel on the V2X Monitor screen. All use cases are CAM-only — none of them trigger a DENM. + Alert level thresholds (read-only) + Warning + Awareness + Info + ≤ %1$.0f s to conflict + USB-C Wi-Fi diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 35855cc..825eb1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "8.7.3" kotlin = "2.1.0" ksp = "2.1.0-1.0.29" -hilt = "2.51.1" +hilt = "2.56.2" junit = "4.13.2" hiltNavigationCompose = "1.2.0" datastore = "1.1.1"