From dd4642f251edadbf34c1f26a772b026185253399 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 15 Mar 2026 03:50:56 +0500 Subject: [PATCH] =?UTF-8?q?encryptWithPassword=20=D0=B2=D0=BE=D0=B7=D0=B2?= =?UTF-8?q?=D1=80=D0=B0=D1=89=D1=91=D0=BD=20=D0=BA=20SHA256+rawDeflate=20(?= =?UTF-8?q?iOS-only=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5)=20=D0=94=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20encryptWithPasswordDeskt?= =?UTF-8?q?opCompat=20(SHA1+zlibDeflate)=20=D0=B4=D0=BB=D1=8F=20=D0=BA?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=81-=D0=BF=D0=BB=D0=B0=D1=82=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20(aesChachaKey,=20=D0=B0=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D1=80)=203=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=B2=20SessionManager=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=20desktop-compatible=20?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D1=8C=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20Notification.Name.profileDidUpdate=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BC=D0=B3=D0=BD=D0=BE=D0=B2=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B2=20?= =?UTF-8?q?Settings=20=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20debug-?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=20=D0=B8=D0=B7=20CryptoManager=20?= =?UTF-8?q?=D0=B8=20SessionManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Info.plist | 2 + .../xcshareddata/xcschemes/Rosetta.xcscheme | 2 +- .../safe-avatar.imageset/Contents.json | 21 + .../safe-avatar.imageset/safe.png | Bin 0 -> 93152 bytes .../Core/Crypto/BiometricAuthManager.swift | 301 +++++++++ Rosetta/Core/Crypto/CryptoManager.swift | 44 +- Rosetta/Core/Crypto/CryptoPrimitives.swift | 70 +- Rosetta/Core/Crypto/MessageCrypto.swift | 12 + Rosetta/Core/Data/Models/Account.swift | 5 + Rosetta/Core/Data/Models/Dialog.swift | 4 +- Rosetta/Core/Data/Models/RecentSearch.swift | 23 +- .../Data/Repositories/AvatarRepository.swift | 136 ++++ .../Repositories/ChatPersistenceStore.swift | 9 +- .../Data/Repositories/DialogRepository.swift | 85 ++- .../Data/Repositories/MessageRepository.swift | 54 +- .../RecentSearchesRepository.swift | 2 +- .../Network/Protocol/Packets/Packet.swift | 3 + .../Protocol/Packets/PacketDeviceNew.swift | 26 + .../Packets/PacketRequestTransport.swift | 20 + .../Network/Protocol/ProtocolManager.swift | 19 + Rosetta/Core/Network/TransportManager.swift | 159 +++++ Rosetta/Core/Services/AccountManager.swift | 136 +++- Rosetta/Core/Services/SessionManager.swift | 426 ++++++++---- .../DesignSystem/Components/AvatarView.swift | 21 +- .../Components/ButtonStyles.swift | 47 ++ .../Components/ChatTextInput.swift | 19 +- .../DesignSystem/Components/GlassCard.swift | 20 + .../Components/KeyboardTracker.swift | 277 +++++++- .../Components/RosettaLogoShape.swift | 60 ++ .../Components/RosettaTabBar.swift | 2 +- .../Components/VerifiedBadge.swift | 22 +- Rosetta/Features/Auth/AuthCoordinator.swift | 86 ++- .../Features/Auth/ImportSeedPhraseView.swift | 40 +- Rosetta/Features/Auth/SetPasswordView.swift | 4 +- Rosetta/Features/Auth/UnlockView.swift | 350 ++++++++-- .../Chats/ChatDetail/ChatDetailView.swift | 228 +++++-- .../ChatList/ChatListSearchContent.swift | 47 +- .../Chats/ChatList/ChatListView.swift | 88 ++- .../Features/Chats/ChatList/ChatRowView.swift | 6 +- Rosetta/Features/Chats/ChatRoute.swift | 6 +- .../Features/Chats/Search/SearchView.swift | 40 +- Rosetta/Features/MainTabView.swift | 37 +- Rosetta/Features/Settings/BackupView.swift | 333 ++++++++++ .../Features/Settings/ProfileEditView.swift | 63 +- Rosetta/Features/Settings/SafetyView.swift | 247 +++++++ Rosetta/Features/Settings/SettingsView.swift | 613 ++++++++++++++++-- Rosetta/Features/Settings/UpdatesView.swift | 140 ++++ Rosetta/RosettaApp.swift | 27 +- 48 files changed, 3865 insertions(+), 517 deletions(-) create mode 100644 Rosetta/Assets.xcassets/safe-avatar.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/safe-avatar.imageset/safe.png create mode 100644 Rosetta/Core/Crypto/BiometricAuthManager.swift create mode 100644 Rosetta/Core/Data/Repositories/AvatarRepository.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketDeviceNew.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketRequestTransport.swift create mode 100644 Rosetta/Core/Network/TransportManager.swift create mode 100644 Rosetta/DesignSystem/Components/RosettaLogoShape.swift create mode 100644 Rosetta/Features/Settings/BackupView.swift create mode 100644 Rosetta/Features/Settings/SafetyView.swift create mode 100644 Rosetta/Features/Settings/UpdatesView.swift diff --git a/Info.plist b/Info.plist index 793300d..2ccc146 100644 --- a/Info.plist +++ b/Info.plist @@ -4,6 +4,8 @@ ITSAppUsesNonExemptEncryption + NSFaceIDUsageDescription + Rosetta uses Face ID to unlock your account securely without entering your password. UIBackgroundModes remote-notification diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index 373a139..f1f4a59 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> 004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x010qNS#tmY3labT3lag+-G2N4000McNliru>H!`TGciqg z<9h%AfB;EEK~#9!?EPo2HA|Krh&?NE?|sbc?e*T@TXn0d%XIh5bdwrV6hR6k#tHf` z5G9NTg8qX(2s8RBKtFQ-Ivy??zuX77xp4-pv|J|oRO z=UlVut#kLzof)}eWn{!M&ssr$_y5QMm_UGFFoOjE5CDJ~03_+}%nT41hS>THfFuCa z`ZoX&0Du)SK`n z$!=MTM);pyCSyF?J#`*OZ8cx@WNk1J5u+v|mK6R}Dk~Nq|2Ad%5=uthOYzh4rcNt= zvI$Zg8h*^(z2gZod4S}5-6B6T zq+~VhzNuBj{8TkV21;o#nx}P_%G_V6G!%KYD{fzSJ%FsM)Nt452r%o$XT;4+VQ#Dx z5q69KMFg<3xHudTz*1jLO$%h5u0GqdzksXfHlMX+pclTPhlJ#tUpgX*NGVl%X6-v5 zUA?2`0>?(1k-y-6ID)}NPy&3XQuC@DSK)2TDPjvTk|kFeFMn)68zp<7;F zr)+MBBEOm=nG5ssvZay34^(-UZiJEQZGLV;fKF zv-1wXf()k~#}FCXT4C=F-b4;nyzr4ng)tPY*5A_v3A;ZD@?hsBIpbBeu^ zhcmTn2ee)-{j19s5rLVBeM6u&IYeSQa_6hIh2d74JI%#?p0dONt z^_opi-d()g*~c^dgpNORQs@wb^{B!VLg}fS`u9D&xJ8t_S2W{`d7DW>#SRWfky;tlsz(e#sMDSDOa$Y3Y~w?AfWqld12FRaiLLr?YeOoISIOxI zK}-YXHj>hOu=UMM?7_6D9L*-ktR%8BRt`wWNEjp9nmS_f$)M)xS^-USzR?+b>B!|& zGM`4hWM&nw$yXePc?Ih-HHN?$0LTCbpsgwU{SuLOLOu&Q_d8-?jcnyh)J#NXgji## z{Tgcb?#OS)QU*olJ&1fSD))V9rDSoUMuKA@Sq=t^w3Cx86a}~^!saTG}M5Sb6j}}QP9-u%Pa=27b2m`i8Z3gd@m^ZFMr?nWy0YRm2GT(fYC&mL5-up z7CyuBSA?^!`Rn2%f;+m7>9W4321Z2qWztCf8jiHkX8`#ACC}XwJ>TKOmkTHT*L$jUz0pVTM_{bd7A=+%Ho7n!%a8uZLC-hL7i%qtzWerKm>?D=XF~X=31A3_zQ*d zE;M-+%%}x}S=6xYq+V1D5V*3&ZBlLx&&+L{Ae6cJAW6jl1hZqiG@$uFCW!|ZLnX!3 ze+6U8qkzm3J&Y98G|F-I>ySHg;ZK5m30T304beO~rlGZUG9S6PjVIWp&C>Sq)zUM5>=0)gZDsRPMH#FLo1*BDA^*6X;-7~aZt|9l>=6t3qoMp-PxaIm8yJ8Y8*%Gnk= zGfP3-z%5cf^e`*9oT4wHr@IVYI9^h|DrcAM%CL5SH~B}^#B zbv!x0`Z5pJnJ%rL<4vsx_HOwZlQis}2Zf2~VBpZ%=;qU9xV&o^Jst++c{HP?9}Fqa zXWVTt#dFKvlm=Y&9V4Ufy0e@&4R(()$Lr8&%3b)M_HXRhLU32IWw#6M?d%WEnT*+r zXqEtRX71r2N!v(38<{t;U)G9^(fz455dk;0Y%30G6%{oA&g+t0nr0mPtPtho`tmGp z1fUjK26upbJ#Vr~WFXOEuu2|kO83wAF~v!Um_B$a;oMuUqKOfau$YcNMNm?yU|M+7 zUX()RhndNq&6=HXyN?Vb&u}RbQD(H1k3g2wj5RKsjs}p9A2_ly3NC5eh1j0HJlwg1 zzw+7Ul`?*`%(Ia#scZ({8c_EVi3X^NeJjspMu0lhN^OY;a}#b{J-YCZiOOdEQ$?dQ znrPrP&e`|YJKlZEjEF5A-)@RX8}eQjl^aOWZFrnSS+ZNO{l*8MD%lGsQyNW;0bP6{ zXU)5E3Uz#YYEzJCwSu4eL8cQr8O^{KgZ#@D3F--ZCr6j`B2e*kqusmLnuVV|J)=YoPia_v{h|6sOseEZMkCIv zsMdY;2`f@36CAubsn0GhJ6Axl(RYS?KrE*c6kcQ#G26SglWMbF(V51!j zLqZ2C?<1h2cizOE<!LsXUH00Rc~X`VQ?u(=O1*EozQE|su?MB_}PoR4Y1QGi>q}Rwlqguc?_W) zee#cp+Sd$Obs**0I2Z~>c_i_;7h@F2kelP$O=h9FJvsEcQPiF&$xw~;x?96?8tE=|i08sJZ86zL;Clb{X#m4$83d5Dl<}tihB-enBd63nk?=^?0sv`E zM6~aNdQ>&Fb)F-_l0?+#^(+AAvsa^Eb=2-l+mLk*i33#q8uS!3`xzi>Ck`ICW`2>% z4ui47d==6ICXel3_jsqUfECbWkOsZT+m2%>$bmYDNQ~#F?sO>-kr~yj@@84rwnQ2f z3*))Aa$Lh*AF{XZSSQRd_D=SWjFDx?fN55vYwV$`a+QQaeu`B3RYueZG6s_KJIAw^ z8{NPCqhqd!OGCpmrH!cc)iUkrVJ2yzfNbY=;j@fBKYM`*m$mUO(eCKH8{Chexr1Ll zJu!0!&&7Y};Ati82@!)HW{?n$#3OVJ}mTo$yPiGSE1 zQaMM#(@`2yL{_Q$+8SF)k8IpaiIrfh{I=q6j*NWcG8313BZjl&47_XJ$uSKJE+l^9Q%Mw zb#RNuhZ@RSzQu#>owYaF<+%J4P#Atx?8!d{1DOTHf^?3@fCJaJt?jnxdoXw z3I)&2vs3ngHyv9RXZJWGYOpB%!vo}0vx%QPC?Xoag&rpm;I@zUP;WIYyMx(#nfkTV zRQmMhHv)^Ms%w04($FNc8tI&2uRCt1oX1)_c*~}gD?p5zn*Al*XV&T6wm&<*S8x5a z+Zh=>FTLDSRXW3o1uCuclb3DbYLww4IUx84(O9*plWc9p15tS1A;2-Hru-%fqA8Ep)XGbZfqGp$cCcy8h z5@{`s(~ujdo}&Bge2qzEqDvi~*NKkpnO1y4eGuHqK#W(Vg_)t)=he3`S`LD%(QVDT z-kjs#*F4#_Yv=&IXPlo=m8e>H>ks!GQLs)>EIpOSqR74>u%j&0SRjE^I0j?@h%3 z#`}eA33y@Wh)jmQzZs-oYX`7kh7=4yuyQfmldqnYy*b$#v9-!&2v>{tEwJOPPFK9i z-3+O?C>*z-J`&ZhJN=)%*0(%mJpSps2YpXm+jpF~_E76poRAFjo;SQiVacPyjR5mJ z$D%ArKCL#3eB$01o7$dmKG^G){m4{cP{n)9)G%$Hf!Epmubr=C=yaM=YP~zv@BIAR z5p8W9FI1-4zRize&$)aKXWIlFH-X-oyebaNvE@`lBG(h)W}sRJZynSP7fP zi&BK{xW;4Na7!v=(dINbLbjEC<;~aUi1_GYM~`l^C~ecoo?)5VheF0H?IJy(IkeCz z)4b)G$bs$SHs+l+u3S%u@y4bQ+(Q`ano#nUgZmnqq(}l|iL#Ddk;>1`0}gOc>|rYgL3wUn-_`@L09U!#`mZ!5c7P4uLssO=KD06E*1f&avk_ zM@v375FH6#I7-I=z^DPiI$p0BA<~*Ywbf4iewwdR>q^XEsel1Y;e;6FNyo&)0ZPSX zCzIl|^ko7xw>ntox*GhPM~YN zK1P+o$z44e^4RmT1j3NM>CwnBpl>mHtd`!?o9iVJX*&P_nOdPOs)wXy6A%Zj5u@2_ z%!DtSXeWuTZ8`kr*iV*g{r(&uC4qmc_;&q`0Eaiq&c7pL4C5&!s}ePEd>6h~AtjKr^psC*6jZ^xaF=?OQimyiuIBr#=+ifl*#( z?CNS0jgW0UB5rUyUfmVX=RQUs34NML27`OqM`CFY@$>UM4B))QV+39-^EH}b6rRjY zUzo?J@-3REBqt-_CwC?#KI=Cn7J*CL#SutUiT*Z{_s|Os!ANK0;JCEKISk#Gpr-yV z2oTLKBWQOGgVCjh_9P2t9VsMh_BNO$r)Na!@<=AIcV6OG<26pHsH2>r0k1hS&XaHH zcn!MK{xuT#j`VFkWUG5oXSAo}_G)ty_R8;D&|1gaT5_XMea^(ajv$5HW>7#SE1CVc z2dCCgxMaF${q|rd(jV5`+sqpjJxBR|noM9{YG#*u-L+Om>K)VPz{31Em#dnK9=j#A z;7j!VK<)~VM{4!!RiSWA)Y}e-^Q-j%oJ;l9Reh<4_Ljvh50;gb41DOcFHNac_Ee8La;swAmka%eSr zYh*Ge1hz|%C0OraRGOxmnNt9$q+?TZTOEi2n7jcHg?@0edm{j~Eq74oz`+7VJx!*! zFvJBNEv<6!M0;H?yY}wtRo>5mgvYXnc&_b#wc4E!vb5Wb%@POKxPQ*_mLl@%ZaL zG)ZotTDPkIFzc!w8u`P-?D+SEY&jO?yNSA6tn^z0niaLl<~>5RCY*rN`$jkMe4S=G zNWmezpHhARpQNSFsy;TK!h}qC@1A`x*YE6U(}U#}b@hyY3ML_VydlL5z1>DvRT(pn z5QHlHqOJ;B?8f<+Tc?mS-wG;ZI9%_Cb=lzJ*m`%gZjp`wG&gE$^{D8bU8pt4=INaA z%#0d2EvQshlT^hG?Cc`Dl&p79r1H%Wta$7sr%`b7aHoIcyX0smi@}1^bA1W} zI^ZEPM@Djr+*Q`!KQf%(3iVwxNfyqrUaD|YgTP4!CZd@nR1FebB=gGQ3AID6gz-%-es^P>@}F`V=|ABZp?F7sZe`l^bV z1#;SOXs2-2>h&szmt@0 z3W1Jgw>SC2@t?f`rMFMa+#|^Kjj*0cSnn^s*4}Y7#~hQxDBn^DDG7&B=x9!t!KCCWo+ScD9O41ZT?D%GsK zoiT$p^o&K7>t5cWzA98_f4>jI#wHsC5w!rKPimxNvyd^gc8MQ}4Ne|Am!={F?%svY z2{Y$B5KCu&8J1uM6N4bFtT9=E8I=Sx+bEz>b$+Y`S8<}5>i?*+VT1UGVb1PQ{qIGc z@30oZw+*l{j0U*H6isCzmEXvGjo#Ger=vUlacdbrq<7l%-9M^>FsjJ`zO@}f4@#sY z#)aS8yV!dvJNtQAhDNc7Gc#?wuYkI(wI7`Ft$4ljV?J4rR(Y2@w79I>u ztEeJ&bY!wtTurZt!Vi=pdsSvPUA!+1c2`FEv5&NFB5qNaP7*+$I^hks&iVG&HU>bX zclCk_S(8rsZEH=abxaae`5@>zpo(g}H)hX>7D)gs%{nx+@S>|NhRWm7nJJDk4KBxI zD4K;I1!lPS^^3P4P}Wi*_c3~0biicJ_irk|oUDPWubLvn{ln}(3EAUE!>k6@Cw4x^ zea>Aj{ZPLlYV81ZiqjmEo-NhFVATgrQ7r(ScTTpBUP)51TOza#NOVtm{+MS%D z9#Rjb_XB0fE+6O}>_z#46YQo_dKR1KLUGw^|4!QLjx^CIpXR5i!)Gc!Q|_kk+e)x* zyJ`S4_TRy4bEwTU4JJJP22ScXQeX-M(OfGJ05ghR&@38gKsa$|k-e()m@l~9Lv`KN zdw!VuOUO=GS4YN}0x48X3aID7T1LL}dF|V|ozRpZx z57Lr?6X6+!!^L{vtg5y{>9B|BcZAx~V+~pv#Shd_%Dv;w`Awri9iBqV3cDfh};Nepmk>=qUNmNIHR6c2yU9FL|~8 z)TZDyqjGb1*iXGW^n6fm1P@ zzM$RxP~W=QUEDh{Rh3y-;pVXVe=e^~-H^MiD3e=Y%`dQqPHzFN!y|l@wU~9&m?DKy zJ=*q<6BQ}-whL&OXSU>=lop>q8Saw6U8a5Xb1#be&<6B+Fy-g;pnkA(=C|cl3L}N~ z@nKlR!&(9DcgM`Pg_q76j6XbolC&ITD&lN%&&(rVQEoC;ijj&_>#gS-%#U()4amY+ z6CGtO=EzdX%$^6^xur0{fS?AU;oU0%-`1O02U=UJn-2}v*)zUwq=9CzTP4QfHim%m z&QaIOR0XQ2sS&e%qq=Ii)D3%s?BW5bQ#=k8;N&R3L1Fv}i3zMwXr7sSB&ew(rQ)oK zC0spS$xhh^=Zwf~Xk?-ubHSEIjwr+P3&IQsBC)2kM|tcXP3ja_p+uuIT4Ce%glJnH zaE+2-)YsW)FB=~A3GjP2pxLlr-oH>XV+J)f1nUK{Lw7DG!|!x3c7v@cPZDkN)sCZ= znit(dKmi}rp;@y(WczcCW(U=#2`O41X33+HOhhVQfyt`k*^~8ykk0U($iCl{39l6y zb}qyjCXrmkwOt!^!)mf~5v3tx2IDaXiSNX&Elz;=k?>js(>G_0l6=gdZ$u%AZW<&9 zCq5MJbAoYi&_TX>S{f3hcE>eOXPk)gNQc8A9dp&$1J+Y(Pw_2)NhKTj`B*N3I8C8jKMVqAbhN=Xdr2?6Hpf^Gnal04$K(`^Vfdwd?G-0`EBd&Fp}_B#>~(IO0O_Id2xpb&uu{ z8h?O&>;ZU)q9;RuO4tz_*Ds;b@g-!p7JB@N8gxyJPoH_t-KB#K((eA`uh}kyTyax( z#aI9tHagnxv=UtFCyZW<9CDtr|81H2;l@aM;pj`64R5hNSaT1ua;-#-w$Cg8tHQHB z=mCN3&pl1NLz&$MipW)J)~Ei5&e@codJ<(V(b<-V!jq#M!c}tZSv%^I?kgfs4(k)h z`Y7u3PZ`7m2Hh}$j0}x@XQE{jcEZ+s*xDfI=udrmagYS*sdbX9LegDGf3rIn+G3={ zO-f`>Ij&D?sT$Ybq(9;Kj-t68?{=cbz`B8Dd3;Wk=P2eR@8L1tbEB?WgC%#bkkui5 zq#l8%2}SKip~Hvn%V&_D$kbM{Q9ZT$tKYuh2wWfC5pq}`6gjIhxBsSUHUyqUQ2^ta zA3(LR5&}qqIEMlnOik^&9s_Ga8D;nncg7jq)6nQksqhrkjq$#Ef9^v`q?btN4zs^mM zXCZiRa$#IL$;q@ij45Vxg&EWF=FG-w=oJe34$%6#X~t?NHX*7FWJXlyupj(_7{-LX z{}-88q+eU@Gzc^ZCT!K-b3l9O8vAP$IGF3N zi_k~R`f->{fNDY$#1rNuqO))AKpX5|)$KscZSOowDBgI_H*hMCQ*R-VwhTmK`WUY! zA6a{1P3BIt!CrUq+(G3Uz4sQEFXSsyqg+5)05P6=z~KjUb=c*TkXOfQ80RG#<>VNH z`~>YyqhWY9Jnp$Q^5NV(9H_4hqh`cNbjnnedY>~~4N#)jp@()ho(Z&s9T_&l*Xm{& zdHI?^nJIMKFt$q&L}3iSmmOepHH%i{?}S1cUq4Z4{5%x>XkBB-lWe-?C$N;*6P|k- zmXRW_b`Aj^;R*Jrjb1p0 z`i_T8f2R^!d0=WxNy`Hs&-vM57s=@lHJtN1F^vR2O-`1MqdV2>a?kY!_a*UMa{0EsikNwZ%a7YX zg^-?3e)4>ht5w-o5k+DH4omBI?|75Gle5~hsk~*=`P9}&(Km?FKb1F|3do1%d+4d~ zjN`+7&Ali$CxDn^1m#84qBN}>qCTI@tKDe|v=%_LZk+ik6l=Mpjx3E&`0|#tVfM|s zMHEK)Iz9d+Ja>93Q$1aGf7jvXO$C95#VwBVOOYDGDV|fJILJPh0A}*N7)<#%O358V zHL-J{imI5N%Qw?y`^aoXOieXVQ}I8zLUeb-(uPLOEC~@ zS?!+mLUld^UP{ehQ}x1Y(EC~*{G7xe+iQmHq|jAR;y;K7?Kr+3hbDq~jsjNDh~sza zC$poh-AqD-2rak(;W2HlL!X@l@OS7GE^O8i&&9Fq&$i(^#@HRe^SUJyjUI*0eJ_k@ zOMocXRQ}e{YpG|cC~w{$?C%~t;HQPIL>p&!(}>PVqa^wgy6-&8OnuccfYgqWCFNAu zUHm7c&_ABB4A$gx44W;Ca~|0ISaPpW*VA1qe}6{H)}VkoIgH#r%s|#Dt<(%_hL?9i zDousS0(!N(Qa3RzbCp7b+5C7ddhnRIeAG0WwFB0IM0A-9-QRIZdQlwGwYwiiO1|d%H9(BWj&RS$&)l>SRxsf}N;uPc zM5Mf+Yv(25GB$}3kQd-!X^dgUZnKavC7fCEXPIjziV49K5D!~>N_|ds#=}TWLX`vp z3n}G7u{Fd_!*+rJBp|J<&i1&a-G?$9hFU2^D}~fza)gqO&sDNDX(_hOIjU3@AwAw$ zHd(z|p(WS@quDn#C0G@YQ8x&5H1_Vx7N1%^DN(3l1G_=xeG=$eBSn+VKm@kBlajgp zA^+9k+#0}Y*J8i7?)imO@#jg-^P?5S^#%so zy4Mb2U}4H4{as7IzJv8yILWKFFQ>gqv>7MXp40$Evi7_kJyk*5W^*^RKUsP2m9(z6 zSX)^SQMfDW!`rdjJT}};*?t&V;785p z{Y*s}kJeFe<%e@rFNMSFW3TBVGdp?r@&Z?$r=*_+5UwHXqo|ldn3~tm=(TI&&9k;f zPWRr|LVJ0@uaj_?zxyOMSsHlmO||U?Ro-S*z4AOpDv2pMm=9^Qos1D+UkiVPGe*F- z!k6DORgZWK$)o72SEx9TkHL7HM2(fgE{qaFy5++8Bw+z?t9eEAJ868XMSkJI+JQIF zLo?8GmJJYSxQd3K%{fst#Uy5O*g{7456|fkW1y|~^57eAP3J|HD7v47YK^nMJ8%Vi zq)8&uU!qk#qAgZ2Whum;m=BJ9005|ki68Wcl!p3>wip6w=ab>J`7l!m#`g|A+@>RkdHO0|{}U|%C3>fXpuex~wR@-&G&dd=SxIK$Qf zKlMTY#=)2)9s@*EDvV>+uqaK~POpfZQp@mYlhm6rCpVJ%QYxA~p(qACP0t)D#DMhTYJiBszCYBaofsio>MPEb~;Wax~H4WuPQpJraym=>12*GD+0z2sWIZ!=r;YM@k@8liZ7o z=XdXT=+hW{_t0LRe3*m^d+IJ;r#$C)n9bWMr9!YBTxGxI$72tGIw3sMpOxaCtDwIW zJ*{(&=Ag>A_Z;&Ok+9j6+r%Dk2W*88plCd1+E+N#uRGB=hbs`pK(ixQ!Sc;nRLQ~D zL_By2W>kz)p0oP9f~QA!(=~S#1eWcdx}M(07H~T}M`2nxVPV*PlnfF;phM+*&1x^L zn1-s_hX2&a#2A=CM3Mp0$OLamgFDS7d5{jqpBc3PxY6qYX1C^5&`xD(`YDT3#HHdK%QkMXghG8^)Br&Z_RuEC>o+q7OUMdPJAxHjqm!?hM~d=>5@L$^ zw9*Jo$PlW5*p!sfze;qC%sJ8EL`JXi^y^NjNq8Z$%D#J5)UzLnW-$ll>bjmqXyo-8 z{9&{p)rC;J`7`Y{Ia{`Kq#=l4xy`#c4mcb#raRHEnAEcGTvwtKbmxogbIU+>){0Ib z9?b{>42Fz@@m(E(*{SG~%GCYVRrJiO0278c^D-g!b6-u|CA#+H*>;|e)h4W0TAK^O zbJRYK1mYrfFXy?l∈K=@?{{W^pWm5&l%YrDVh+5pE;p3$TLh2 z@Ed{cAaC#40=B@&!^mdzIUUD=@Q`} zIt+#hfyO0gq^u44mdyU00dRAg5majSPqh}>tlp8R&EYwwfcT9<6P#mr51&A4qA+)v z>5N2>0hy6NTQ^bN($x{VE$A}5N-vtkPdAdhH95M@55P@EXMYj1vONvORx~5KuvzlA zxe@@&$Y3G%hn2HuY|I)kqLB8uk3IKR$FatSsc(ap9b(yZlNH)u5|OAKSdw8NtkIMZ zwQnsm(@fu(srkukE&7OS0cv#o6vieTtErel6D<&a3xY$QC!uny(Tfa)sxgrpN*VNe zPH0{i-Xit25HeJPAd0~Gf_cN%jl3l%eADfNDy9WX-&a{mq6Sp93biMAj45h!n*?TP zKxqbekOu`F)-0TV7zeOCM5B?NXJ#mRPTs=sJ$l7=30Vpy=G{7_kA$nBeLTuQRGG>P zF#A2Oi$$=YG2lEYrxX(dRRtnq36@fVf<+?!AUIgwIQupCbl|6GP2#%`Uy8z~lOk`H zoN&rM0J|Z7iRigizGlkG zv+`Ue!wxrvfj&kUEUosPk8Au4Mc6N7mZ%Qev`SH!|<6;g1 zOR2C=6t-_$Dq0J(Sg#T~ex5`M)19qXny1i9G_4H1~AHiE3+R7-HwK5b^iQ&QjtsAILotazcdY=n6+25oLOs(R4J#-kR>g z#LZz65Q3^&^3Ju@{?+Z-&BxE>+i$}d8b9cM%nJg zF#YWRAeG0T2q3kCQg>vy{2mW`f1?*8bgV)!wz!8@HZ# zc=kuno%#85=bwJ?+)0oCR=}s1ul~(z-~83VzXDq=tVt#-XHWUEr+>c2hRGqc4SuXXI)20TFcS*VCf@%I9e~0sNh-;!FP}H^ znp57}Q_3*qTI^0U;j6%5clO*khf2FjR;xMZq2I^@~ld7d+aUSB9$ zr;y!#2^)4(dHX2|6AA(HJr{ruY8C+IcsaJjRRCBr*OC}i5evbBSF^o4y#Lj#zIN}) z)f10YzxeUR=byUw;WNjN>pda0rbD1g0KgsKvs*Xb{piLU@7{jv!<`Q=(aq(tWp$nx zCl|B1assSKg{O(ZL=lFJaLfV_*fdt?G-ei5W3~vbL6xab}aydweoyi zC&`Ib8lAJ2XrzPL1)$e#J0!$4h&+RSur^FT{cmfSfru!!da^!vb5QwJlPsGDNznjC z`Gvl=6~hIW>m#WaOyj+&r1pz7-P;9lM9n4xHdE2Cgp^Z;!Ne??0NTK^mV5QyP2RtA zY_{{nh5E;j&wu><*%zL^aCSbQ0W~0Mu2_nSX4FF1nVBV)M74kbX234$PrttL)(2Pq z_O08melq*w#`Z3bi}@+BSX2aJtcY1H08M>R=0-AVnT$RQW+9ub4A%hi9+^iawO=Ng zb0beOa2~3jw!Xx!P_uWV)F98_y>B;1?sx5ZL3_Qzoe~&E;mhj@WWeOmxPa96vb2Da zzL%ryy_t%}^f5|<*ZE;1Bj2C`eU>i(&4mPJ4TxlcG8cz&7FoaqY7YyLy4Bt_>|Z@5 z?p!!kKl{+u&z?U1!jmT+xp4d#DgaA>rK}j05FK*|L%k37i8uFmUj69$uiw1+@`tNW zugtI9-CEHJnr+SI8x^4jBr`yQz$8H;wE!sHzp~0dq)G#YOx9xf{GOI=Z%x9G4g8Hj zkYg^#zHVzziwaxom}-M{E7`Sd^seu#m-4lGEsvpT)HrPc{5K4sVxYm3stimgQ!&wj zF>>kE&&-iSr>R)#e7D00fj2Yo31^`;CwIi<{fKdPzn}+UJ{9hLkDo2 z37>v@sn@LPsYRi79|C&@8xR?67!_z(;Q|oyuyW@QZ#u-oG{9 zyS626oZG(h?1PJ+J$vHWr_Mio;p7Qa64(donk(Wt(Tqqm2*xq0Gc%UVD+X6I0~UY; zF5kKP_Gj0A`|hnbKHC4_tJSr;+so<{Eza_6LkPWcWS(oqIsq+HAJl%n?gGGmAfamk z$aJk_U?dIGJ_|svci&q8s7ZUIY=MMS3+0r`@E8zjE0M|JQX2!vO!pzv(fK9(+VOb0 zoD%uFq|PrN%@az0ymt!)8V%5`>>uP>m||ER^lIWq`$8J6>9h$nNyjW16=o0&x!lLy zYqQnma~rqrzgNEa#EBQ5J^S>-=gw?wE|iCdL6R$iP}_xp1*y!gh(uEo>U?<_sF`bK z0E@<|sKFm!x$*n=F8|Hz*WUbCeti4v4xbYnCpPDEF#{1;>iVsjJCL%%8;atc&~tt% zq`|X)uUmMdrEn!M3ygnx`NA^}5!CC=()sNr0%`a^+%;gx0)XI|c_^3gv%-+eLBbtW(j5z!szc`?%aLt<7>Zu^X6+G z$q&9hc5`_gVtc;W5*tLSjyJPaWSiJQ4Yj!f`H*i0D4e+@k2?3BQD;y(FPT&Tpzm-E z%bDob*0umZMwl#RyLe-qqqG40H$zzP9;Y`V3s-`pIdgS&JnpXT>`Tc$KAi~S0~1{cB*Sf+x3V$hN^>_+f3s)z#756amW3p56BSS}?4 zT^&YK0V8`a0AXZ21xPAsY5=>3yJifY{ZYJ;TgJD819Tcry8vkDr%}B1hC z3NVtUsydQEYfNGRq^?rb_u5oDUTf6U5$x;Bin@2ANl!B-g)w6_K{7GPih-IHdk-qc0;1fc1qi!EN=eSu!)MU;%FI@4fQTm0!R9_3Iz) zzH@ow(wz&nIJvp8Ef#YkmQt#kbfg01I$B_WMM>b$`se`E{|F_#>SGpw5?}Nrg)Dmo z^CqW4I84q0NJVd=IGc2W}qf?@b zlWK$jA(A_` znkyCrLABhwDfcgJ(v1gBE}wsR@yi!Zzwp?F`_G=(fB>)ra}7c2-}KWK`(I5Mx@ZAV zCq@I!<9Th&0!e}dDQV#IJGb8W@ao^Zb@jCmc0a#_o!#wf^NiRyw-m=}2>_y!X6p{S z*jyL5fJTmlPRigxhP9BCTY;0=pWoAG>JN#TVf<_mzO_eZ_D3cNoY(rJ(fM8?I{*x* zJvpWx?Yb)(jwgHB5YpN)*a8a%RWluxzuZ<+lq}D{K#-YXw1QePlc)$n0CKt9y~Vp% zPF7bRxgdY_+=*YlaQ@jx&Yhmk0Z3peA-G-;W}+(Ks2_3ict6LmaR+7Wfac7xEVC*z z2c8S<1iOa%gD)=s{>`s`_xjaOzm(VZPV9+$s_LSgZ}AL*04bF*pqj8304J{^#dCZK z*A%Aj^+KYu2XPNj!swfmoZv=qT&{hQlNZD8s7>&P0A1)Wdbi z#nz)U3xJ<)b~K)lVcuB)YTub-UQv6}4~pm5n0UY~ArRi$OWpDT(M?kf(#S_aL=c48 zYPa6KCRSgaIlg@E!TB$rIrXcj&OLhH$t@5AD?ru^LPa2g08QfAkBBaQuuJz3D2xZB z&zz2VZbHWuskLzz`1sn5-@p6iZ(qOu_NV(_Ufw3< z!b*Ee1yxiLQ@vmB->CMlY~sd)r*@uybo)<#eDSAG-FNSaP1TxYuw)Wz@$ke27(dv1 z0nqY6KLcvg#ei9|P^&Aj4}879_ufZezW(NwH$J@k-dBrDyQi1cIk9n)W(!FWP+Oj9 zpA{3^L!X-LI9vBG>6V)M`+^aMO?f3=<V{gvK9*Sa^Qf)$rpf57m$1Y z^duQlGK1?%C`iyP)L&h?^49y`y!GDgPrk0-{$_S-d1{}|$;FwCMa4ucL9zx>t>L7K zxpqrVd#{50-9tBHxi!#3&XhdvO++ONK;QhN^(_ELoH`Q0l5(MMhXv5yA{zr((__x| zD>Ac3;5sq*rcK_HGPoPal8& zne$IRa`B#H8*`urmOxeWoG>R;hq1Y6()*tUAmkZ63JZXu8Z3d8WM-@?p)v*Lz^>f? z?8_@}esJY)-n#YMPpZ#v9IwUM`NoNjg_uFqpcM&KoJn(N>F?Sj6!0CoeNu^mDQlot zgfFG|8)ObyJ7W*C)T0V(G!l6LGsDrLqDaov*wpW~_Uqq$o zOd**-d4IhN_w3>-y{RF!J_J>mj38@T-oXA9-oJL?*xvI`Z2j?%&;9b*3lE<;z5oF1 z1Ki+#Dk35XBh~4J+9mV&K~8D?CE@X-vj8}KWL^QaL`|p!Z8pE&+`0402Uq^`jqAVr zkUze*b!}&pW+&zwn>3pN0C+%$zZFXYh)APHO`e0D`W8l>6r~mb&l*S-rN~JvZJnEN zuAQ7UU;(i0`_91Z_i+J8=|L&~kNs;eNNB$0^-*d8Fuz8T$Rd~{I&<#5b8;MY39FT7 zmmL5ra$sMZVzRb7tN+8yAVI1ofm}+tbBpAqW8&JQXLetB?8G0xaQdgu+<$&^!9Wd2 zDT#STVn8*X^-*8^Phxh;&8g>``GnrEu{6>R#%j2RhQ3 zzywlQaiW@pLgGt#i+8@7?|pG@d-dF-$N%K{bH9A%+>`g5uAmWzwd9IXHRvEZa_W9e zV#(vhDPZ4;1)%-P06{Vy%w_g23esO*44xho|^u}T#1c2`os(4<1w60@FuhwxxYX=PcdYg_9<@n++-bv4ak!qKz8w6nL&GnLFBQIP=T zYPq~A_P#jIpFec$?u(Be`;(tM{L|+jx$nf5X-7&XVxhKL|F+Qp6p%-D^4C#V0H%6f znp8lR%*+s0y``JGci;Nx(o1jM_``?w`(Mwl?wzV|efD5i;hGYiQ ztTJ8Q|M=@0Z+vj|H*ep5<&)JHH;&)INx5-SRGSrZO>-$KKxVdKP-rR7X3fdX5?my= zfY=X}an7^axd7x`6bTE!Wv4*$*##gvt$P`r=xIxhq+BL&-)L<T>wJ%qRT+} zfbiP1vUvl3tYtubmV?USIBRWFWuF;9MFg&Sxp#AU_i81tJ#zN;pFTDJ;}_1p`1m~! zpFgeov=4AylM3h3psiJo9t}VNWnut_75?q@t|*+O(@j8T`BIuj>qlW|v!J4RbL{rs zop(OI{=0Xt{{F-LcfXXkcjln&JG1*&8^`9A9%@VifZ2>*y)QN>OuG&lu;y_S1O_P= zr6cBKR)H`UZKE;=k$}Pszl`QrLen~h>`DK~EdYHRDuGC@qXQktcMCsY>+EsVmj@Uq zTL3bSprA}!_`d%#n88yG(0^kTlXMxPy;UT}de(Tluul?q&t1o{h-o1M2*4|0X zk1sanVlKdtbrUNfVq&1J`C(_NH%G|!{?CFQq_PU^c~?73AbhQ>;9-QbfB3m0@M#6x z#_mw$dVvnu`C}wJq!`>`(a6;$v}uySfGn^zy0?KxYL6|eSex^!D({@H2?3=SSX;HU zU>^pUga8xdYPHqPd z762lWmU8W!V%;nJxf=j5v={C?Y)LG?Q( z`~1e@E-upiOto=bP*oFDrWuH$3xI0X12>1STjU)f<*FBOokAedYmQ1;0O4sVntTCJ zts2Qp(|Q3>XDNs^0ro?f(D-|Gt})RQ%vW7Q+;-ZC$Qodd*yGd92m_*8zuplya6|-a ziO`HOL)Ma*xuR;mU_tx!&ULwaX@(n*oRPnJ>ew%yJNx`&7tWnHHV0N}=XA+no(mF$ z>Ih@@*Foc9pehJF9{N>+3e``=*q(#b{r!~>gu{LHC8dFK4H51vsw z=&p2uFs=v{k!WE1-$P1T0Q%E+WC8GcB#Es_M4*{d7$qZtGd6*@003ZBok2NX7yv8~ zk^Jz|^*_9K>960s{)dn8<&9%^a87KT-Pl-EAOY0GjbK@6Ca(6)76TMA<8x?nC7C*v zzlMK(d8mCNum;v`0eFFgb-RF4IoLys!S$q+M`iNRvDZlNqE7C1D53ff7fuR)_HS_d ztoHnii!ofxbj@X^suFVniCx~kQSE)c#aHe${aVhslnmxdh?$8N?Creu@#SB? zb>+A3t={=^^XBT*vN~QZw&&GGEdW#Evxyc5_hoMl2pu(K!KN8hKKpmep)s!7NgyyF zC&U6BaMah^*P(O)m^KoCV1$)tjrHZE*8*T8e>gqMXK=m%*cvGuLjg651grl^NdSo% zD#5h6&dV=p@7lSIy(jNm{^;>@FFt+W^H1D!aWNxc1=NxSV@BT<Gx>wkEE_l=MD zKDs>L!->`Gyquq?ll`fHMrb<*Scv-J* z*1K01_031l-ucCoi$8kdo*zGc&wXc(&6T+ga4kVt(038MIW?_s0npDb8ywE_GpIWe zPo;ECQZyca?_hU-?MW>-(wviWS!H-uImeI{&4CIa;M!{Uy-%;d`p%V?-rIfS>-puo z+bcRz&5zAy3qe)$poF=f;lx(Uyx;*fyOWl9{|iZJR@z88dPvBTG~;$cs|AE8SO63a zMqXwV`C+*jIVKArn;|D~6W@&YKN(C8fEMb-uoQgP*RN&{DJ6t1yU3_kx0kES)#~!; z+1@%tsl(-VCR9eb^ifL zJQn|u~=+4Y;ReR%zE-?{PXr}C4li|cz^Vs?5l+pek&BCG%j zTm5IIcwj(K(E`vbt@Ysd12{-8(9BXyWo20al>`W~;%Js#q3jB&1huIs8o6E{wce*x z`gk@2K;`3_gmnT`-jy1@L?F3b?cWf4SGMrgBd2zsd1&)jFWmFu)Av1i{?rDr1Q=4( zEXaZE^Emgy_lF|hfBk9n0+765;wR%x`Z5VL1sZ1~ljHXvlOkh2r+Xy}rm7&S+&HMD zB!(0uOyu}_y?X!iYcIWZ?RW3qee27`<(=b8I<=T@&gOFnbsejx(%#o9I_>U1(j>{oA*mQZt=Ci#fqwqK z_j?@GxW^2yUh#wS?daBF8EDfl<+;i(0F{)?EQzQhmUraNRo?yP)cpFR=jG2HKlRgR z&;RJrd+s}RT%ErH_G@JtqtZ6`50Tx0lpI{FVVywNG7xSB1yPLuG!xQh@k@F97+90Mo7k5fo720?;rt{N9Dj!1xWR z$55||fS&iik~JXeov+01>lcpI&pvYekDojHC(mAd^4@dilBHx~7J>zlN+$b3-5*aG zWRCLM0~0I&Jz3(Cr6N@PqYHqC^heX7_V~C3AS{V7cmeQx11QLvZ3l!MXMh%{x1bt-2uF4SI7q6joe&)q zP^YtHau<-d7G-`-k-a7Qw2MJ%uE;L{Gg__oc7AmBcmJDz^zc)UK6L-~%!p1(Nd)=< z#xpgQ04VdXbSB)_7EuVZrOf5&7bBlg=4SIId)994;M#}w)ZZK#F2=ThR!QfMOTu_q z@~x3(>*44G6H2Msw}6y0Y7e-)xBt;scmC@2JO8)8CZ3WF_xUfsD{ z1E^{_oBc59ubs4a$NC&ne6kE%aOx5`c0*(;H%Y6_ndJo}6lqsa#A6P)9)CA0C5}vx zTEC9hM=@$`3$Bm!sNV$1`DMelOBDW-0YDb4BDR%40s^QMppL|?JNu~j1#123HB(cL zPcK2|=+VLN1X){1s#)PRCBRMl*M#Z3MoKAh6ck?0t8b}fAEGARCXv}d)Mn`Z;OhBS zlV>+@@tGx*QvbV$WeMkQUdzDl_uro+Z?6OuZ<3t*2d)3kff*4?op5jOGZXFaB(950 zU%msxj9FFeKJQg+L!GQb4TeImeJTNuP;f81=b1j#I8rt^K=S63K z6f6Fn#?QLv{X<+=;ub(^MeWn2$ym(#aLk7kj%o;3nL4ytMTg^q@drA>q`>J0qCTEX zz#zI`qjP5QOHzL3a5-4PF!Qx!R_fCW=!HrDA^&)D>qwaZG!EOSuIJxnUBaR4K28Iu z*Ir=jSSR8OKv<+< zWv*%PG%-LCtZlEEsxRhe+BLu}ct@RQPBr`69u#M`iZ;KdC`!I7-jHevZ5ow7f31H` z>il7y3Jcq*!!Xq#exRdH%*?fvwUkows+P5sQ&|At7An{A3!CbBN$%I?eYcMIXCxQo zHNAhfzd@}5V+=U^h}~4G&Gk8mTC6%zix0(mwEhxA(25v9`R4O=YTeIEF}_lV}Mk@r!eVb46snaMP&)F zRkoeO>@p-LkG@6U8i70AEP;=Qo4;GJ$w*TN5D&2ZE;Cp3he@Po-XSBqZ^S=UqjU^$ z5xn~cIykBUHRIm}uRsey3j%@ep#J^mmw)%prI+8ivU6wo(R+B zU|%O}V@?EuC`;>|mNL`y00(`!qxm2y8lr1}OdhN3ErLzI{HE3i`FHbv+JmuvaWtj^ z#2qT$3XvAqKnGOjLMH}bGJ)6R_KD*64OJ4%EGq)27H!5_x>ph4=~ToFvNr(f(iB8f zM*4>hx(<>!dW2B}`z%+Cl^`K#1_JQS?%j7ky!_Hzmw)&6%@4j3cXp4XdYG&EU%qo= z>;L=7gQws5`O{nf;fv>=eEj0Qr%uj664(c1Ei1x|Xl6ZF>dBQ$O>K%+*XhW`8BD<* zFKZc`9U7aHQlS;^e9Z=nO|{>zjvAN>p`aR2`(^0DaH>H8ul@ebPxPMV~HCdiwZ9;84}DwR3r%40e^hy>MQSEe(9}SuYa)m`g*nGQyUu(ZET%5 zb*!oZ1`*v4#%KGt-+Sr$|NU<-o!$EAxrb*zfA-uTJ$Lbmhc2A2LFn1q_p&kphzP-^1-&Z%kLzh@wUlb*_Vk^SK$x*qXdHCj*E?cbYhwU# zO|U41k&wIvlp!kyKmb_k)l~$weQb?D+bG;I^?RM{@qWR1rh>P14Zf3-Z{+s4Um^{C%JoxGq_s2q5n3(YoYGhpS8kXLNU$StO44W zZk_B)iLE!+v*1mZMrM0gFB{HICo=bG&P{e%&2lSpCL(LHwR4*YK&^{fd#Q7gnYJ7@ zYHSW>RMOZVR0{?$i)L?S9)@W#1nLY9E|sW4+$3S;)dw%AGEB*edcZ6#L%D`pFC=?N z<{xN1#O*3eHF&p{OkyTTkXQn@!Jl8f{>FRX{QmWC-uYl?>*<+=Id;(T%Z#MWZT zf>|z^Bv-oPy)eU7hhbL?$Q5`lHXfR9KDZQn@7=ud_FrB4fBe;Z_Z)xasfV|J_RKv$ zdG`K?FP=U|M8Fc*lPtg$Fe8ZXcR}y<88qhoQhC=ca=V>y;?qKEmMl@I-Pxae`<3i= z$ex#m2s2b<3t^*)vRCc5nde6DoLfTx{;<6ZGzzuWd?1k<%#6y;C@v39SSos<0o6|N z?mMtxjvP(rHuY*~gPCivtch1!f;MN{_d)DlT3!G0 zttK7Hce#q&>|{iA2jKl#x4d$%?=)b0*?S0Bt9xVHNSGur)T9Y1m*>cJ$# zhElP9Jxpt$7kr}=$?d+aLIZyUseph61(EJ^bmAfgfl~hc5|52EP%yRdBp{lKW$&np zXs!em+Bcz3j`c}_Yz-|P4QvfZ8L zh`pjtb))Y&wHg2vGr)RbxLe) z)DV(EEJ*^WfXXh}BSaE2)+mztB_V)W>`UI0bI`@b*2Njsw|V!sUtN3Ylk5NIfAjvi z6R-W~!DByv{+<_~y71_Q)2Ax{kibexW>k8MpCUE$yFuk^K<^lUDYi$(nbv$O)A}P# z#vJbUR~YPRb~+`r=0w9wI5H+ck zHBbvMP}T8!^{)GGDF7uSLuR8O%AZJ$S!eaVfeF$NF@WJ@WT)~xS5iu#u7uhs^Dgk^ zty{0WfBE-sUwY%+yPsVWca~c;f0!2MH_jfP5o!t6!T7oYK{)D<{Y>rLz2=B9vDLTM z14|&mGk|O6n&(VHEKY1}JOZ(ErM~@d-oE^A-ne{h@!8`S=RbY̠&_soN5E*v|y z0ca4BWWhXBN|}@Us;IFF3?~sUe>o+y3J#yxo?ESF(W?y=ae-h!5>r!q%XR_9qmnT@ zAQ~XFD@}x%AL&$CFSM&q%

Ai_H9PhmLHoOi6JBw;G!j1U8*^y&r9{y|B-z>V2V2 z(CIN)#v$n4p$dYHA$l+Hz3L{>F8CD~`(R#e5^svl z3o}~n$ellYee2~+%i%*<*=BbO1JaqcpVtWI$0+s+tslt~gJ?*H? z(j%z^|I@cYa+m8=DIjWh=_tBOIGMuHRtHO9ycB&h76#lQIwl#=(9B*FJOoZg7BdFe zukJ~03-RTsY1y(E2;PZ>8^K4{!-(4BCj4c>6~zk=AOIq6T(4ff@eiARm6IyJAcH3xtu>ni?j2yn{$R_Spy6vs??Ba5DL!X zP1dUtyFZb;*eiWMcB1~(d{Kjz!7Ij6&X`YNcA}c!zpDB3diS%pum9WEE^Ui0E9p! zRK!Mbv(URKV9d#0JGdab`me)na8*xkC*PycW-{@4(7MF{C98otiOWg0*m*aGEJg|3 zJZFOnUL}n|GR+wcwl?8)nc9GUi$H*xIJ6Jp2Z~S)NbB8XM$I5VkeaOU4eED3zWleZ ze*N+rw?6)Ceq-kZZ`?cII9r|Glw!7G2$qmFqavzQiXL138ucIB#qZa5qlj%6a}7BZ z`WD&|M+63GkRMBi1VL=kiLDy<-D1A8clYgAZ~Vo}H#QfaK637Z7apDe?3t48uc5v9+u{1Zf^i(NJ<0j)NU%{GtCaX;7m!RDPl=I)qN#f-R0(qur{~} zOeBdVLAdj{{ggX$lT9SAi?IPSwZSWf)hR;F(re%TYG!5zWMU_k(5F&Dt0w7*O7;XU z5s;ldV8iT6AVvpCb}GYEb(i!*)$8I9L0~NhHHEDJ2`nX+%pfrn5QHV@Ht^}?Yp=e0 z`Qv>Es>Gr&-e|Im(7cfZNJs_?A!du?w0Vqi|K0k|%b#5P?FTph`G5b~1Lx}(p4|ND z^Y=aT*o6znPiz7Jb^)$sMKo`~OXPA3*C0q8|1OcvwVz09F3j68%C1*&s4pA#+~|FP zHHD^Hrr;~bK6mmEZM<|#NRR0@P7vnqk~1f!DD;u(v74$So;s30-#$+4L#8yQcnF10 zMX+FLY5@(;9QhVg;~T4qYUY(>NmL1{hy>^^@ae5v?|pdbwRbMP`Tm_xzM5U%J4=gu zXB(&a#72$TO0v>|i2$0baE*5dBIDPwXMe5xTc^2D6VnXE=gpApwLvd74k&~@n;<&$ zn3bEFpem0t_PGL`rmfR6QQxXpuU)?R>gS*RKmYan_ni3sa_k10j?DV*=^*M2x?p@|r zOAb0w{UAGXq)37#^Gbq+sE8I7ErBn0ci;Nxo0s0W`qq25KKYWb-#u1~i?hu$*gQeU zD`xCSpoSnOpb`RVi>^S9or=640Zu=MsI6dM?!Cb3sk$qQg%gdbZ_Sa`eNO~#y2Z@< zbtNFg);ZcfyHE8e%R8UEef8hGdTnF=`Xd+d!ZW9S`oj6=9y|BY_OT5BdNE+3t6+7H zu}L5zhfXn@%vOKH^OF;I=Q)NFxlqo9nKhs@Zy?op%cl#I?QAJLC+b;>vg9lPP34M+ za63h&_9rS?i(&~Ef$5r3Fq|?UaW8-JDkEDpjtm=(mCcC>#=PDXJJ=$$0ESc>mVUQs z*~H|Ox4i~S$wVZC1~^_r{pqC}FTeTC%WqzJ`=i}2E}y)M3maRHY;11NE^Jh!b9yB+ zfvSiflWc{^o6|jDmkGNhT?A>Z-=6LxH*yz)NWB4S&(!0m8I#I9@RkABssMnRRop=E z?9A-cDS;w&v!beiB~UX+G7(oq zQjOjoK6>9KNbNSr@{95HliC(LV6Sty{0Ycj?tPue|&I?GL{cU*9@=?8GCdPoCYl zusN%UxmLpx01EkpmdXCB}B@l)rXd;Hvk=gyp{s0OV7DJ6ia z$t|!wkKaZkpsBK$dlqb;+$^OeIs?Dm%VQV%BYv>cBb2Am0FB+LeXjYF+v9|$d{VAo zGfR#vE5GbfNw4}MUL%9D+@YAq|FkmIZUp_Q1clbgcY?G> zS#qt$eI*nI@+$T}{q*W9Z+!FH*KU3A@&28?#fr{vpL|f9Idk^(CIh!6h$YqFs%j+Z zf^o9Oo_XJ0cZz?JHSWW?xDD{yapS(Ps}~7$e=@d1J2HbxvA*ufHfoqiC6O>#0(C8x zV4~x+eY_G6-QoSWuHSy+-(UWJ|K*jFn;$-M@BGE5PyXoX^N&1q?&67Ksu+;~W_m}n zHd7=;sk z345M_VkJPZ=kU$Q=i8=lWyNCa91JM|fl81{WxBq%^XhwF|Mja^e*f0~mzTEgtWL}~ z9$ai66Q?#R;3k743)O~z6hi6v9+uJb^F)>FC^E+**D^~*N2d{s7b*yOdMzQU;6ag{9j!?xBc;xkIaAi%(0(6ckko(pFdlP zIUs>rGP%)$0oFi>h#e%Zq68(jTzS?(_sO~grY|U{eQ=!nRhIR&aANf4{jgGfaK6u> zwvSn_OmX43`B*4hko0~4_Qpb(fVy3Y+1`lS~=Tp zA2nEm1c-L_7p}_~)>y1`3k_Gp;YEi``zlJpv;wnaB7&efp{j5f{Mps3FTH*B*KgeT z!@K;=wT=Di{*7bj=4TeiDoBZ%v62kJoWP=W>oh1vEoASG5zo6!s!N>|!b2ygPIr^N za#|iIKSi~P93VU&?N2J2K+G(GeFnsAwsm&4eHP4DR?9EnzWJB0etmNB=HvG*fAaLn zUp{xw3y)s7XKPCU`@jkyDU}ip7%N3iELnvLNxf82e zK)M*a($w?r0BYV)b`wORcd?J>iyU{wL^<-+#UiG!#fm^=p?%zxf;KGN6(#q z@tKPcT|9S62mpJ)o@9Vk7InobW~Rg!mA?yx3Uw0$=$CI_=!QxuyMv zTvYC}iC6$7&Dr&)qnVR!Yvo#hSxztM3Ooq&fm8Xt?N$M~@3ntBw~xvgzS`8u-xdRarCz|9hz0Ef`;cE|YcLbReLT*~0Y?s|?KZWovrg6(J z#|M*-5n9~jLO*R*6TmZX?(;vG4o3TH26!z9V47W0* zukSzc-t!M{{QQ}dKYjMTNAEp%ys8M;2bPk-EL4TkwF|6Bq|wh!&*N7OGZr&p($Ft* zVD_ki7l!C$!*(bYvaa>u%Za-XGYdhU1>osiaSI?Sde@QClk)k!;b&_V5bz8Qpy#$hGa-0P(>A0W==CQ%zI!J9~=ZcjqYZKFJYkk!Z`2&SODSN z9i2*|22FFB4iEU%CcJ0?+N=WmUQNTgzp7jsF@c$D24?fcNwIlS;PEf&`op(w{C{5g z=J>`t&s<#o`0?#uJ$wG?M=#uS^5h1n2KE7#Ow1KX3e1QE>jIoEBS4c&!^{Q?WSBSj zA9N{0Hkwn3r&|MP2D7&N+slpI09W}=p_(!tm99x6Gam9lR4!@Fv{4J~@%QFoTZ>mn ze-BgVg@SEzcVq!GW=MOiqK_mzxz}bi6OzYC=oaw2Qnn(N@%X3iWy{o8+zzbI{i$Z` zF-vACNF}05;M#KUqc5+%`qt%F-@X0L7u7enws%&iXNxB`7Ta6f*dkdo*Id`KQV?_9 z=T;j1cnBSu8H$cNZ;C8)RgV>2S*)UCr3q09`L6Y1&uYXnyZ{E7#3d=YW&pFT?K5=z z+&=L8>o}|JXz4E*w9;05MPlOUacA7>wBqx9nOA zd=Zyk`!)K}660VV!khM52G(RB7_p?)T*YwvwJ#Sl(`RPxtiqp?ON($QPe}~XzA+SV z{N13|A8Ot!&(O9b)kcU07-xG1`%={8@4I!=0TDBZHi!sx2mHyko4WutjT@9P7NIxyg@Q7++1;0@O#Cy1Lzq{e*nSZsl`D}n$suUK%+#2|5M z>-Z_AM=#aOuRgv0SMPnj{jWYgeeB&QADI38nKM6o>f&P$o;tPIo`F_C&6p+ZfGE#m zv28now!pJT0cE*oXF72=;$t=YYp z!BUXJYooaA15ooz?h^Grd3YqaTEJkpK=`PPtIlJ4-ID3WG>C4l_TTvI`b+Ozf9cKL zcR$0`+gm)lFyA;m-`+lUypk%L6iwAKfp&GZ;3VsR&8qgUd}nXqCR8E3`r96YU7^S_B}P0-^k^sAKv^| zZ{Ip0zPk7H$Im}ZKY8->&!0X2)O}}b4Dy!tB=bV0pfRdLd8xWcfx(;Cv79RNL)35{5>I z1UF)B>cSl^Ny$RcfA^br|4;vyH?QtoxLcp4#l^+uiS09UCHR`CZjxy@mbPHO^o*wJ zPIyiUW$nT|ZlO7tLt-@Oz{VZ!HXFr$6p*%ZY_ywFCW=RnjhX3jV>E9QiAX+;(e8sq zA~qB0k~uyoM;#Fi1}|BHX@>dM_62eL983CackkoZFaO0W*N@F!{Rcmq|L^{*KanhE zK-B{cO?`oIZAYBL(vHt}byYIh-Q_WT+`;Z)PlTtEhp6!CEM@@kGJbr(@H zfLbt_{Q#_jg&n<3t$5>?!Az{IdH>OXHM@POMj^5-fL`;yIzI$v#NI##SX<&pLmjA@ z1?b%`Zh!vG=7oD7nu`;XHdcfh0;KYCeN;)FhR^=Z$|p?)%xC7TcRrY%O^vNl46uh_FX_M@r27 zrGAJ$0{eqGzi&7$kF|w6`>P>l;}{)hlsd=yOTlLZ1wV9c0t*7zD43y-I?A3q8Ve90 zuALe<8Q58{J#{2G(;0I#*aR_{woH9Zmc;wDfJ7~4G+RJ|D!n6@X+&lP(04L)`W}-n z_t{`Wzn+QB`7*4aLV-iscc?ICKGqSeMkp7oJF?q1mwohSD_ z!2|F%e@oaNpvND!(0#Wt$zaL0nFBps_iEB z{+l|jE3G7tj?aqG_fC7k{87CN){+vdqPKU^<Ap4ID^pdzuE5E|hKzfbAT@4Ucx>gaeRCqT#NI^rmu)>1f_?IY4Ym5CV) z2q9OJrDOqci{k3;!W8So01 zpz9FGx>@@U~gvO9L#){8*Z_8ZX`piO{;?X3bCjDm6!hN6_=d$-T|721+i~Y6? z^N_Ne3|Wrs9Q=%iVctTY;oz6Uvl7aUhRjVvG*Nho^OTj{r!@qYS|KVF;HcN5qnE8e z7M&u=NF#G$=WH9!_#WhBO!pC+yUKk*eQQkWw&hMoF_A^Yw_>Iks)aBNQ zh#|eH-}U~BpdB%03Gl)je#c`iyGE!Tvy3#}DX19a*B-abuAj4M*?LBK{7-CF%;*{A zMn<97$*rg+GR>v5p#iH?`YwKYh4)ju12G(K*i^8#b&4jU9Yilow#H*MplM0reT;)Q zs-Ywf_P9#`l+*`p&oV4!wopXgT+pSs9SO-BJ}I$2dAPEas)1lU_lnhdw~is%S_{K_ zO!mQy#8Xz?u!lg_=9-xqywlT@y4D8ub#iErSkD_nS%DytjZrLwNC?xL-_SJ@G8DbA z-=^?p4a_%M#*Q`MoE->~P@kyZE#fV;FEKZBBbga|7~ElG27}dFvIRyXoWq}s6jg*7 znc-_y-LbtEUXMyguv3vKf(VLh%_C+1E1)Qv1}pAjW>>PR=UOH1#!+4co_1Bv}T zL~~P;J=8rEP)1f>$izK~8EA3~BCnW@gEB12;Q8p?zt@9yv)?!(CfQ)Sny^CBx)(Y; zka_M-EClh^ocNHd`{yBu=6ntjSIOQI&}LADX~xkQXWzg;46E9@bwsq4%qx+(>tJgj z>d)~`2tik&ITm&O+uDs~(RYyRD~ESgH^AVi_=1Q?1a4)ggZ6|q3}EA-t&oep^z)O3 zPoayGq7zJ)*{Zd|Ty%?a20|q?iYDknB$x8!J5m@?{idlv-xz^HL z?o&aDs7$Eiyrt}?*T%2%&apiVnfAR{ap%0Mj#RGlMK3w03t}W1>ay6x^beOK-+Jn>aRB4EIC_!akN!tP|VjfNq(8t<@g%b z&yMrhQfr-eWB_v|Yo=`_nVzIfhcVvCjHXazb{!SnUvUiM(FGOH^_{JzMVU7Of?;#m z@`?A({~GG)L3k{AX4rUWP(}rnayyLC$~{LYg3(TqHQ@+7CNJ(#YBX4%OX9$Jx%Hwj z3>Lm1-gF9UX6t1fWQ69qKx~>Nu1QM4UAvq~zm&O1{+rYJ?!`dDf^P(|k;t;yPinow zkD<7G-D|C+9RI3L5dad-^U9&NA{|R08G>Kq&*5kt&V*)90Q!2#06j5Ca8VlDy?z14^%cah5Q~%rUoz=vQ{FVhzw&D zAL{h;)KA=v)dlAy?q#2o_K?Ba^soS;%aON^jgL_0 zy+D0wfL}WkOw011qYJ&<8tW$efslWE`h_Yr-5Wxm1v2RnC}6`0pIW`1yvqknsjzM5 zCB0uC<8kTIp88_#if-Oa27X^GFmZ+rxBC%utwb2n;hG6<*?~zzo)4$wDP$m#p{FQA z_S=^^S=1u3QDmw`&+nh-J2~=*J%=)a^&@PpLDyJV5VsvP4v(h8{X#JoKkeT_Vn6~C zU^+tfGPL?kAhlI-m$??m(>b2{)s{mr+ol3G>N%=L?dF}wOIr7jE~zngRJp*&i)VPS zmm-hnqp&>WSFCV0@%^s0P>9=&XDRpOSS!5jD82*+Br%~f+d>HT%l$VIL{epN<3Weu zpMAWSm`HbBS^sA}J8Oq=jyF4>FSvBn-rI zd48qLs@3^)lXJb*cptTKU9WLY>`JGITfm1&)MWn>IR>?j3+fA6$Wu8YsH+{rYWp|W zQ}Pv#82XE27sd-4gm*@25!e%qC@BqeKmxIr(_rZKEoc)oQ zh*KjZD&g>vmCS)5xF*_Qq0QGhIUAZ)1uhFO^a?=laMauph#`Esss<6k&MOY1BC(5N zpryDJ?7+7g#G??JE}F>hITSFBBunto*jpRTt^`a_`@D7Rf!sdaY|eH`oPgnIAC2_f zdX|LnGc!oGM^dkAWV`4lKRpUbh|c~Vq|G~yKI_&b!3K{*?N4W;Vw>X;yUJHE6(TE` zRzfEA{N7w<4l+=2qtMXG+D@Ry8)Q9kTf6{t-j9mpajZcVhiJcAYY;aZghkFXVn$_Q9!zGP7&*RE#!(ZIC--1$ z!WWu6soPl}Ch0|7jxH|Y$d~}(s}X}X27UAmg25(&?Qbc(JgaOL#$%m#FQfZ}aEtI| zE;K!(c6$EERFv^khtF|9%j#pn$%Sne$t2{|nS7g_>hnFaL8azM#OrA*B3hUOL}Xtc z_u7@q-^0R~H>mJ63}4=?wA=jva`Qscf==z<8`cC31(*0W!~zwXQHAF;Z5BQbhwJbe zXb+2SPAD$=HoYEVfGQ?7cnVw5?t;N}jhobEPKd}KuUa$B4PqPK6=^u7(DmGW3UD-Q zPs@&->GaONqp@N=UvMV$$4#D!I#mszO z?+xi40-|GtQxCF{fF&IDHLw!2@gSA^(g@kx-7KZ!dmxUK0^!X)$WSw!6JWL^rSUrIDZHwW3#I*Vw4~RM`BKP7=Vy);BbOw_bWfPbDMt z!=y0lP6xe6(t!=L0T8TIq^j?&?FexCa-J!1)RZW4*{%xFb+%nq9KK^&qaiHBbJW@& zJTvY5I2{x|d;MRQKQJJ&nC@W+)IY_aHZY@$eU*IAT` znu)6a;G8O+6liZ0+(MiQ(Ug{qH z@n#y+Ujw&(C)y`!?qniQL%XKgD?~z0PUYdB*@|?OJ)HfCNHRcC=O@s5X2~4a@%cpI z8K?c`{OA;~zDVmyCNUCZ@CM{2i8y{$E3!ZAxiVsj1^VTn<`Z$V=PnOf{ii0;gv=U8 zknK{7KrcM-rS+9KHIBpGO)ZVsu01)7LYpHrS=Qk@>ZLOK?8aKtkP;(|1STraPpa32 z8?2d29HT`2X5ULe{#9y={k|{(5Vp$bGgaX$a;$`oXe4*Al1G_0flrh3zvszPN6dLm#4mtCtD}VKna1PFyVJo-Y1y1 z1_pSyBrj!eJ;~?sqe&M;#gW)4Z_kTVWbQ? ztI$>mPsluiVX@zXr&KKZTUqc4m!v+&HU(ly4B`6cSx6>H!eKuK z{!x)d65wpciZ|-=Wmx?#AGm8MgNHOR03W;dJ)|*ufgW&2R{c-19eJ;!Vi}Z`?tEC& z$m#`S9l*SOgwxghIa}yo{TfDjYWn zXh3p__xh)ds?agm(TCXP*oWa|aUk`E|5+u$%yq&;wC=Rl2*!hQzn0KoJ0Lri==ULF zBq{6Cd_GD`00XL)J_9mQrO1j02HFttGm|X<0Du5VL_t&lK(-E3&H#Eqg}>BFQ`od#09MdVu%H0@j@r3UZ%F6c6Tj2!lDtSiR9a){!Z`y=CWaCt#;t z3uLvwUNpIr_aKR=NRdqEB4~T^X!(JxNK}_L7qRYp%{%6jk zu*^XTC&S3*!F$t1EwGwM&ky`=9d>ccA5Ez`3~SBJg2|i~eMb@XQ64%~EEvSZppKQP zNry25rTuGzM!Yiot;7PEr;c=PE6{35-5U@@JK}uk4iCmgOXy9u-7@xvXbUOM!!@$Z zhu}Sh_HUHQ!Q|y;*4lIvUVP(V1~|n*h!K*F?P}(qlqToS5AX<0zQhtuUAG{V5nL_lAkBiQ?&VGgEr8l83)S@fs0M;N-==&FAO%Lm z6e|CeJ#`J2!E<6>iwsr5gIx6OmB4dNl&*PPrs9xxdZrjSfWD@FFm)ghBrtm6mO|=N zt<(8jE{#kocxOS`@#H018JhLA^74WzH&JD1|HcqUG1qhlGwND79X`ah``S`PRxKa^ zkZR+tqhtY;lj)c2$n$7io9M{-C^^!>(x33>t>mA3pK&euUc|PPucABO=n!9 zBNx|{y+-YaqqP7KiIQ2=9U|S$juGR!ArO6IQQ_$FR7=nGAN9$Ev<_^NVIvGAl%xW5 zN`_*aoD3e(YjTHXSSpO<*>bZ#m-FkH#RDF6G@7^$fKUeMM-;UH24yPRnn?0|OLNTM z!&`5oW8LGSy}Ph?_4Ik0&`;{oj6r}rV)H+k2u0b+jaifEcwQ{63dSO<3AXh?6D?pm z?3puhTiH^S7`paX!2fG2!^FD&Q>PY^k<~Ctc{w}?R;TLYe{IU^Xa!qk)RPJTlzFJ* z7QEJK?Q>G7zaA75Zm8Wyr5BSYH--fZH3bJOh9$4;28-qvr*^Ws} zKeQpd_SZp!tqrd{k@^s;1r*eDG#{cvu$(h*Sv8hybowUJ*^(j_RgTIZ)MICe@qC)(_T8RKiA|}#(wIma_{nakYrf6l@oUz{WHL%nNE#3(1!8zF| zJ_j2~4z&aX?{ma9R;nR4`(drq&y(Rfor0c`BTHkkR#h-r1UA7Dp(tpr^O8g9 zL6k$!i)DAJS75-9?3o8}up($b+Q8gi{HD#^JYc%Ld|KB+asA`+4*H5$c#9drU_Co^ zGol?H)z4sNaXn(z5jmwM&B36te<~9zHF5|Qtp>4lWK4jTV?Z|bRH8OeN-}nErK~by zcdx^J--b~M!lajfxf4a6X_Kfa?o+_ewDqq%{s-%7%+lK-py%D2%{Hcr}D_Fy$AMYAAd6kn1s$lkyvC)0WI^5tXf2_R* z^hLJB_f=iPegGqw(L;xuzHOafd*Yws5=t!rY)W!f(+b*^z48>)8Ep#wF zsD19ERfWKzROrl6fg>l_yb?o5600?qLm5T{u!w1>{UC!q@460DkKeH%ea7D%sK2h= zA79aRhGYfKoIys!83punW(G`Vc%86j^>i`0hah$**&=b6i8aNs=)MuWctF$Dr2ox!oP_xbh&}ygUNs3A7HDd8B88V^5RxgYRD?kK&JcIi za><7uBGOb(Q5>lq;VsUJXYi6VqC;105}Zfir-#kb8f8@&!Rm?4YmKUyyXBCH4O`8` z>O;Vt?oYsYX74tP}*O4}HcQN%k;iW<5~X+^NjCWj?e~Z5TkQ z7f32A(JBaP>;UzD*SbmoF`K{E{(gf*;6^1yTg}7@u{kkV?#Ol-HkYPqBu7(%J%cQd zIreX~wy~NI10*egqd65sMLAIjzWoq-egce_?|c9n>-n%nqsj9ld5V>D^x$!JDK4t#_W)grKDY2^o z8_trfWApV$klBYN$&p4HiCD$i}3>^{j5PBiL3+_tLvvnQ+v_LmL zP2^E%-pOMN;Wz=j8c^}n!86J z%uzKzJm2xukLJ^RNFhrtGco5HVV$c}f2yHh7ttgt7?-XmSmuZh=$=!g#2qksI3HDs zh?yZ;0NlZBy0kt`KZB>rtl6oAfFy_+-Z_9f7`RCd4yk0KAhqXUSYLZZb&wny4K^M5=^HhRqM=b=_Mx!?7E8d z-_jK4{{1fjoOnmV>;%(VpT}Q%X6XEJf3>7MDEnsZ#AvFzTL8awH#D{^=5&^5-&(54_Oy2H1fN*Vz`Vb@8 z=fh;z0FI49z(|;O?p<13?Km*vfD0pm9mB6Bmm@USw3a3_OK`=+M#ssp89ERKkZ)O! z&LHX0Z;cQ?G7n;WU;1Y=}WSZ7!hvfI59Yaq1)j7LSafI_~#tcPAXhZ$Y6#3t|(UP`=a zV<_XJOyH}I(A3j6Xd4E#n$L{v$`Qtm2562)Iruby)>q+clB+`JvNNHy&mtYKT<3F2 zG#)wVg+0ivcams)f=Oel7KTYN1@5#TYspX%c8eZctt*^CRspq?)2$QnYA0?$bnL|n z_}K<+wnez#>;XDn?4X$CHB>yw0ceD^va0x%!a+`ixHK=K=zTD#zC1Jwfl;iNPliDE z;*mm740uC`VQ?Xbg~ZP%rj=yUM6+Z{rrnY^-`;`>s`OhkwwYDsC<$VSh6URr&!V{t zLv0-(n1bm!I5Orj5P%vIs_-uBtQN(y2o%u4Q+$~7q-Ve8c-}2Mq{H97dCD583b%2>?V< zu+hs0-@PxAXF%7=PVTPqR!A{0uUgM7qz#jQBKI$zl|fi0Nd~%`gs`-s@e;7ZhN*M> z+0c3yq<`7bg33s0UKJLA$Zzn??mJEt`5eAfA=)GnFn3NiU(=~}xvy=rX+4^mZ4G+D zcV+-J_a!qzhS?>}d*|eH*y$Z?ZQ~wO4>g+0S6nD)&~X8^_?z%5yYcpOLK*V*C+%Dj z1Vc2v0U#L?09SSg&A2rV;Zy=dVQ|Zv=%w<;yK~Vx#!+r{VFHtF1$HU{P98(Lctbp! zL%EMqKpDrz1o*Ah$nfwKi=?O(mc zBl;CF10p1o*Wz+Z2xhtipePckO_Pbs2HZ4&7CFUBN7B?z%Z^NAHcoR{Z09ZqGIvpWiMP*jtB}3 z1-9KvvRmOG27y?B;i&&$s9n#nhUjHj#1Yn#lX@FRm8;acuXmRePMTFXLHk~AM#88YMV)eua5#Nct4G_je}^1e$4j$a z{G{(S2E}P5Gngs6ew0KCqu!x);|cpxr>+w<`1(?8N3cLN`eu!mmyy&M2|BB*)KzTs zPZ=?#vC^IO;)9-c44l~55>R}SY&>*FsUmc%At~-#IoO55IWP|LXdgS~Mvb2|jO%8! z#k`R15B8JgM)S*E9%yP&Ur;9}!{)mt0SAtF+ZDopHYF5wYMWaWzWW;2qicLQg`vpR zqn)d?m>R8{0I+mMct0_DP$6n|*K(c+#V%t8K}ucM%8b+KW9$@&7r06-MLm`2Dap2g zJ+(eK)69~wNwACbj$kF+z|z8wEK)o4h@n-2I<%e<p6c0FQMdg6lg@na>&Tza1G$M$1DGLD>`Mq17}~SFOW`Yx@}OD60{3BS1=B zo7w@&&cE~gU}3EbE7lcd(rlxcw}Tst7LvSRE0dfQ(2boIZ-IvI*upLhNzaC~dkzf!-@&G)ccwmoC4{tB&Pp;ybi9WA?jV`ik ze|BJPM=%r2iUD}N@GTx|qUqF3eK~yVwDk$e0~eLsksPbl?v5xwLC3o)RoWr77eZL{ z7lC2nTxfY)JkArYts&GbY#n1rN>-?ike__Zfe_kXkN&u|fuZ9??Y-l(%z5xO)!C?560jquQ-FqJFM+O4Z;d4O}tHWb=tXaA~wK*TJ` zO1Sl!F{1EVD4`IuWFe1y*-Fxz054aP85YZwpTRpV+KevC%wo-%ovFtctvrfmH0u3Y zhR&RA-yzye;1qeN4;$?l1%w=h71dLqJXNUhh3Tcl*#eVNqp=QI3QcbTKW(z0M=lUE zt3!DiAz9&kcPKh>WR&p`(vDVi95ik(9!Xt3bI=NUb#%l-g@!$1_v<9mE38xz9;~H~ z`ZDX<)Wc?*f*`>tyj(FGNrc%glD=mhdXCqDya)25)H!4i{q2tk12i8sn*hoACES0eVYLCdm;&_9B~v z2?HN;yEfSQ!)+$B*Rc$HE$vEDys;R*`$r+qW<){Igu9>z9i(V-Vk_Z98t*WE>=CFw zCl`RcKPSup3LGQT3oDeJEw2!Dmyxg6NC=Ga6x- zToOx9&*`&cXx)Xly}6Iw3Y5{@WBWqO@fdR<2CGXaCw)u8Mawp1vU%(l^tF+tXicI{ z{X@0e~pk`L^n8>3dCvj`DrwG@q94$TWhKn8`ejViXFe(I| z!>x>@M@~@L9PUIDecY*R4-JJ(;I$(;0YmX=%zBrxzH%^3vRjPE0`eCDMh8$T55{-l zs$VUakpx!^cSo$@WFePw%tnt&&jMI7a}ARAAWBIr1S@+iA@Ky)H2Y_Ai31!L6Oq&) zxTtF`oimYPz?Fvj+8qP1g%u_Q*k^W1Hp?8R4yMXFhMIoHI!=$09yrkwhthIixjK$S z3}99X53;Q`vGNf%) zA~hCcG169&wih^j**s+#(DnUOe$UWPO5FY{?i8m!>zUfB#b{jTL~;JTmO~$rojNdJ z4e;juOcF-OmCdR8#w|bk2^rP9B4Y|Dej_u6A46#GSlpd7-b%ZA004VSmJq61vAq%W z0MQ|fSV{TW#zX?jM%5QwPb&}o zenzqI0A?xv(Wr993lxBShB%$Q89#M(CJf*mw6USe!>$;vDxoI*aETu&@By^Htb2jY z5@4Itbh&7aE`_P59DR#yC7`77==@{;ahvDT znr_x#GC>9XtF&O+k_Bjh)*oZC9e%zKy+Ij?rW}8mMA9{tlM90(G*7430v04`B_*7g z^c{Yhhe+*gKqMGo$>7C|h;+?cRh_@7(Qmbq^+lIFk+cor9Nt^8*$e=dO*tTmCv;+a z_s#nBhO8WU>mjsatv$#xYOddx5WN}2sxbp38Am2-$@7!iPc#;r^);+DdFv8+n9<3R zzz{@xOWxg=z+tZd0D$>?W;V|&;im-I8U1wtaA+7sxms7$E;}_xErTee$8rJ1+nEG* zlAqDMGxHW(8>nZV%|Y-nm!nPVC|8b$Dk}E9Jxc>{dz8pyaNvBQ10)^6%5~3!^RACd z5MO2u2nT>U;>B_7&t`AW=DQIygN|r5T*$VWR9y`ceYB(nH9B2IVM%#43tKBQ<0H77HwNVK&jx4V) z$t&$y-nKCdWBzN?$D;?63PhilBT*%9h-Tnn8H6#q%+t`>Te5 z1sk0zuDxM1h!akI0I<8u1ks4d3#IrdGUkZ#J4Rea1&Mmz4+Zy;i2-Cnv-av0uLe3v zEZ616$!>@O$+-tQ_QPc zfSWY#Zd)?h_XK&^eD&Tnl4=g75zwVQjaVB7zuw-ue<;=88o?ZgJA=0?ftqXV*9noV zRAv@LP;LijXhV3GWYn7*l>o^svYC5UKS_dH8pHxo z6M$?^tr!g4-CqjYQt~(NV4_EDIe@9in8Ivl7_@OHhn#ZDj1EmuH~o#MW#Oc6 zA7ncZAjlb+OlqcmPjipnw^xG`v)JR-5oecAG)^pLLP*(|e5sSi zTYKe*+qF^@G#vhFNhxB6hA^ZsqYAEO8eKWOa|-!yI8k5QzevB8QSWx&_ahoDW+{(t zX&2C8kpW+?fV+E4O+KTi&Ex~%!$MY}h>V=W2Xwf|?|hGpM<+b3jRCCV1@_*q_08JZ zjqj%;3%nlvS(0E$JG;w)y-55^os(%t4agIlv-zwwQsO6%avgAhTOjMR^5l-9JoY~p zEg+*%d#r|E+rL5w6(FK(06YOkBfS6&TFLs5>xGM;v2!DW^T>`RPVHbR7DODA?&|{H6SWU^E=UvH1!lHtjoW^%h2Z3WCobk++ z#&yTg`m}x)qKFl4_d$p*;4G)uCD)+)i=wd^_F@u967?ACZwU75vL_J32?917Q^ffn?Gc z(||6O3MK`X=T210*)GX4yIi%}uC$u(V~MkYn!TS5NdiE7_3G{pQ#F$;B#~tMv3AHU zU}N}Gu(6d4vqb#N5*j&vq7;t-OdbUOR#WE!){F)MNQ(LG-6hbw3jf4B17K`| z&Yzr1UNw8a`;^wlLxq6GxC(9OX+ksg9T#f~M48Veg+q?uTM)%8!A0xlh0xi)UKk{1 z70#Soh{!prsR=rgVUnofmLY)UQtt1u5JKHNsu*8#}u=Y!?s!0l5KOI5QKxN5mkqY??%86Mm0oQYg~&gEqCGq~ZzM zWo&aoK@Mbla;n*`xIvx!dv`8j zs4xx_SYk0-ojftG`zz75u9{d`n*~g=r`0mhz!uton*i?Y$^CsHXdxjK;symTaxb&8 z*Mle#tUf>GKPVm#g=Y>}430`BaK20CuQz=cb~wZWM`1k1WKLm+yY4~^U?y(w9ECgp z03hIjGm8rQWP#=~!#9Y0stJt}HWqwpd$EGv8OA%mS5HIymHAL0Z6@H>?s9jU=v68HGuZI3+C(&wbC_qqV1bAkHJ{j|lUwr| zTot@%c8?-^b=Z-10ymwDI2)}dgw>tfJN2r@f<%0<2YXY8P|-Y<2UJt&&!}(Hlgt<@ z87;&*QvtD26h4=T1#q~4R?j1BA2)ETUOGq~A%OIhlwhwzI+a-L?Wuhs4^@s$7cQRO zn$1_B8dTeFP84XE`e{5TsjMZB9}m)QGh;VW_S~Bguv@7u!2_|n^;qsMiSHvDSe?v1WB}JW1}At zhU(@NXne2+r}<$1%Hl5MbA}7$kFqt%)*L}?w1=^K;p}`9*pq6cuAH&Mgvx_Z~d6P{o&>>9|ao z@rX-?(9~(J9`2{qP^_s7TlRkU{2t8WK3=X7tT~%FK4J5 zBj2^Afgq)_{)XftU#u@W6#djU|Z|UCjUl z>C!i%NbXHh`zguOyFG=#cAyU>!l2xRkhegzfNcK3XWo1@Hnd8_o?iB}9R+J~b;yuDhy|R12^;;z7bIAgN zB&ha0x51N2q3=ghX4%%WnR}Pw$#CE(@8@z4B=2qnk@RvnHzSI_mIG{%5PCRmhr`>g zSFdCUfxGqW`mG%?oen!DshEL>E^Lsl1+6s1;?wQoEonVS5jQLb{DaUdqOR`WK|G^8hGH`_M#%9T3FRNb!l!_N3;DHaA$lrC0hF9 zJa}<)juu}y_>KcmSk3|o2=I-&%bh(rn`xwQXRZIi zqoUKyon*dpwiwb+f8f5h(bcHD`>_Qewq%5{Q_nc?40dkxN`tFWcmn~g6B!(c+N)|n zb!@lZ$e_nC#wQrH1C(ocw(;4OyNc7Ht%nVF2ZUpif&@;Tm|r}#z^bmOwkxz435577 z3W^cfm5?aD>I1H>9)nfbW>r0?8ygIb+WvdHIJ_hm71tIGl(Z!HA zxRV2m`Y9S?o{Q%viJB(m?PtOp(GGU@F$F5K7b?3o`;`g%QV3Kl?`#34jw9E}4 z!_28A%pSEFhnnYlbM*8EEg;+$hE>E4cbn?%*eTnir=Mvi<$Shv`8wa_6^VG0`AERD zULe6(0FU3l!K+1br-AVsZOQB#hofJ$7e<-d9^Ap0CSB4Tbwq#Sg7_7eMA zZSMxSvQ!i;WgJXG?X2PI!qF(M9+S$-tM^WC?B9FOiG4tTgpqTxN^RWML!qb`cSAGTMa;nt!p=N{mwox&R)tUhwz zmZ(-uwwL34^iD1exAw(o_6jDIw2+D!lnj!56vq=nFY}`B5cRBt$&Kddx?(Ln;hh*v za&_~;GmG;lH<^dHNWGd%_F^z#lPCgKz}J`eiDxW@9RmolRfYlh^Gq~20mP;wiY1?y zJe^`?h`q;A;^9esP4y*nvQ0vXvXZEnP#lOz#dW=Nyg9=5*6DNlc;St4GSx0%8Dv6p zN5@Ka93vWi?M(4RU@)rc+Fo_(`VO*KodJlT0otvqn|}YrW7`WUYbF<_g5U)xcR&jo zxV%MgR~{sYvR#h!$lojK_3E)o4chrDA9A0BNUNJx8C@?WVTQyc}W>5)sU##LnACV4wuHk4< zYm9|^@D@NO4>n=56S!iCz194yZ*Gt5Z-k#<$>=l!h_C|gJ+pc7BvtjUzR2oD-^p&m z9)(bXN!mnqwdf>IaTzu#4=`<0`s{!PQy1pZQ?2`pZ$JIe@nNa#NW3rhJ~q8LCxP3% zx^#U>)nbL*o9M~2@WFZdP~KGa!T`f4l~S7Dv(H`$q6dMv)ndP8;cc}Gs1}d{f~k@@ zcX&P)*Yf9jKht4_el+c53b^{aqX@;xn-t>w2(CdAi}$~{p_?5<-psC6{Q>O=CX#$g z%pbd-P~Q>EV7Blgz4=pokn3$96R7kXABj3`y}<2b>gC1}&08^GS#tIx8%hyiN&k#! zXJ~UP4Qj9n(SAt)a(|;*J@(K!8yIHm{>|X=ckT`4Ai)y87pU!K5P@?i9%V5=fUe!z z|N7dFs47^VeWFO5MyKfzVCp=z&UUCgo)j{w)BicxcbzEF5wVc6jrd=^Nmw6|Zei5G zvhm1@0Tgu0i-ClyeHaq3B|8Yo?m`COMf4JNsCOA4|z0>*N`d0v0vg zJ0Ee<0#H;7)SbZ5n0@Wx*fh?EbP1Gn24LS_Pf5%m;VU-I5o_MJ!vOkYO8+6Mxtebt z|Mbh!RIBa*mJ!;g0V4xB*THxIzR>X zn(9Dzq^j1G2dE~BOHD;uPY<45#?U1&G3w>rhwojSJ+>tonZcVv_fI9629yW@pMG;! z%7vtv!nNmyfqI4QQLPmJaJOXHZ}fUm&kz(;(w)rl7K9l;EzXY@wKl#Mn>{n~quiES zs-6DOsKxCCSTbmh0SJmRwsYXBVGD1Gm#|u#`uOVVtD8GB67_*Bbg4Cffr^NM$L>4! z;D!A;FQeGhzC|3@0a~lB1BqI!Zax3#u}y%xl7QD* zk5fTqR=^(c-lzMZ4au{@YnyH0{J!_4On7Wed??Cr2XHoF>!hq_dnyjsEt}E{K(la% zF|p_^LwArTqj11^J)Gp_3>|Gbx!d~-XDQrDH{ZxV1Y3jh*p01?lK zwDiVU;&2997d!3whLCKL>pmR4zYdbhroxRiB@V zIQGw2w5f^A9r!Hds;XCu>h4dTIt^9K-)tjSOv7q_I_F#L5^xLn=<8jcZSOOf^w{fc zlbK0?!TE_R|LUincrR4Tg!|{D?rX<*Qe8;l3rfpDmw-cTlO$y&rIfhIm5ENzhP$(N zcY}E>P$MNKy{cd(PQLx|Rdw*dW(Ff#xa)y{JOTXVkO4mC`3U#c`D`dTMerxyf2T$z-6}7KrY-U;eV`g9`=<4q3^UF0a zj@A8F5n~6Wo~ilJ7bjMB8pG1=3p1bd_l@}UT*xIXJ*G2SiOd2J&f1Z4+)6UOwa&Vp zN7Pr4HwZ?M<4WuBY*^||Tjn#V07k;(Eu2R8otS`%qy&jmZ+(0XfZC3|lj&)v7qbQ^ z%np$N@RP^SY;D}CP#5NOHnMW$63EtUmBS{&yjJhfSjo6R1_3~7+>XhdjJ`8PT0&WF zxknM@eWIQ=u>Xi1;CGDyqTS`qNA8*3cVeq%uCn1L9oS4HOb^_ROK1;1bOezJ`23rj z*KP_DbK(l1CQG0nWx_w6pJY5)6NTA)$<+Ha2TL$F@s312nNbU%r2Xzafw@n9O%x{N zd5}U3$s`gFzn0^U2dJ(YzXyTpT8ga`AAG)Z6HKBj5WId8L;#++@7M!p!17MTktNC1 z_j5a-<(JjG(LEJ;3Q8~XaN^+`6^(lz!4SfPdMks8n0Ify`1r9cKr|^}Sr^=tD}2Pr zkPP7MPw(v28Ma8iN7Nc%od?+(yXrH$`H}h@avDe|BCOHz zT+OyWzrtT!zERQib0G!)I?s`kPm1|-k8CcNJED@3rNvYm35Y|sT_m5euMK(oN)owq zC`}9(^7NoF^PmE%DLcS3nL|ybaK;-S58%W<|3+DU+P6&YW6>cNCSJ|zyFY*C8~{Q3 zMQGTGoUbcOr6E3%EZ^`PVX#DA-)Tu$0`Grzm&7KdU_sPXHM7C|*c+Y3Zfbme458a> zK}VLhUht$WUEZ2Av!Tc4?KukiMBD#8=;Amc!N|!zri6r-h%4MV)+^*>I z=)@g*{G(5Hk`#wSr;@O?n!zfjdVlAEbE_8~J5@Is0G)E4L^#nzftmmzZleD9tKDk8 zRrj&;>;@w*X4XI1%XqcwTl5e`5H3B)D4|bnCUBbpz)lv_}Tu zfBRMk0?7Is=dF3<+7qXK^1K#Ck8Vc{TGc1?Yo%bUTI(S&V^5rXAH=Y2vOBuZ|*B~Jis2w zM|LF`#!tj`MiP(^w6}ZXM~|O8zp+_M7Jz8;0Q|&}OwC~1LlAlkoQv{?60-olzOnbk zC0SJqiCOsyptW*o**(No-nuoADEV%dc{0hi00umb=3dim2R49w3&1O*+RMvl@(u>K zkd)K7&^g?dpd`fSP4p>{Yq|xhC0C16Z+(38hLuryh(v@H^FwF0pL#&7_IFf~xG=io zP4|VFoJJK2Z#ot8K!@(ud11hF`!~w7hod;in06uPuKfJ4%U*1c1F!- z+U?nN{=QLoA&G6!ub$beS9dA`Ni&+;wmSnSajvX#j|Tw|Ec46RXGXJp{lzcXQPvdp z$y%jxYt#W(X)U?UuqXVBurnf(VZl_szeu5BfdlFXVB(Z%#Dv~l_Nv3EYYQBAEn z4U~Yu3i#D?r;g8dYF;vGt7v;DC?}{}B3`}Gyj}*@)eFys+Ub7HpO8;9n?!>tp>W2Z z{Jyc*xlyJV`}^0PzrT9){E0ou9y61x{~5E=Owf{W7kKNV9TLZD$p)q|Qc?C?6nz}` zV_ZFlLnY5k)z;t-#@ju#o{S8gyFwE0TzO;-r4{1%4#;-l+wc7rJh&QQ0buRola1KyvtqL z^?-Qf2}Z&aZ11_2ClR)L6!6Yr1TqawFK<)-jiog@pQHGnUhn{=-moOv+WP3To6Cds zAZJL{d<@kuU)ZWwH|DvQ-5))LYd~{yh|P`OqT_@yL_xy&fJe~R`|qWF_GOq8sR?5S zk8Q-*=^gFsfO^HdCpPZ>^2K{sz+ALwSTx<0$O{)pU9o zjcFf^!exXuo@|_-9feZrxI+3X(7ChTXZ8Asu8=~x2xDOsdX@dVgiEa2b4|Y^gLHqr zfT_XA2JlYSdnduz-@X0RgBwrWcXG)LBEg`pyn#qB3Q+$|e}mryGqFlOU|q8+!`x`n9f~wtf_a=p8AF;(|J%hjw>aK#Hv^yBn{)e+5S)3{VN$V}9!XQ_nq2 zvc4rI-h#gAdAkf8kVfW(c2cTz-!@Cm@nTd)0u$bDe z4ZBJH!U-a}4ZQx|_08F~tY^JMdAJUYsQ0*#ti{6*!nwDIp>Hi1Uawo5(i;M-Yi*Zb zU!JvVe<;E5uwKLBUd5qg>kz5W!8NV}!IO`tDVTXj60r~@h~-aU0}_g61oB%mT9Mh%1i$)SB9IkiW3Mykw7{hAn( zAQ=_W=QnPD@cDi<+g_>qS}o5&Ys z0Q~uj=gyzHJC}Qmdca=uWzs8ccCb0ZZ&YrvHq?9bkNdoBXX&F{udS>vZBL4QgZ~GqyigjXZp!~+|9;&uFsbT{i&y@ue zV}9!Mt9bpRZwOcw<^&Hz8<_>wW})}TXJPivaeOqk@f}QxR%z74#0O* z(0&Ql$V73R4`t^oHriKMOsMk5J2c2Mb)?94D~Bp)SNE}!^IdWNr8hnYAUlh4t)GZ7 zhY-Mj{PXi0)zuj@vqWOb_L3FdDl5ZtGD7i=Zz=TxN4=UQ@%+AXiphZ(DGHC_6h=99 zLdaF_cFV6GKfCuI{`ejSgoRT$FnitMj=^BAa8<6}cz=Jfd3;q{iCTd|J$}+(caNqm zh%lZ)?QK-p$Myi)`jo3MViGMjzwhY!h$t}^G}!HE-&7qHHCwJ~#{AqXuV20mK!`Q% zquZxTs08gY|M>B_w#GXIOQwwr?|i-U!B^J>QGNI;002PpY0#hl_zd5@J`*He zy}<2@AHtcuZb``t+?k=xu`(~-3FS0wfQ=mXov!Uj`HHR^hS+QUK@#e_XE*l#{(tbO zgtZe(W3M}4hmi>8;_*^Tu7KaZc5NTqk}9>OWwf<&BOnu5Srgf@3QvsDB&6bS@#MrE z3qT`ZwvN~#2pHXmNkNB)=gTFf8S~ZJ+S~zn?zFORkE9r0I2>mT2x7+kPJQOLuY6ho zb#WMAt+WtS1Aq35`%WIaBkG#jI8s|pw#4e{+5>AaqPmr2bo$Ut3cZt8n3W2l;r|0x zt74yfI0`1yAU!;*EsebI(ZT*FrOz{}S9ibu*)yk}x^Pmd|It}eBgri*zoUp}sA*c2 zI3x1{F|~Dw0a4w7{KFg9=Nl)NTuEWM9AP2-Gv4ml5fd}$tjee{`6HfU=)to=L3R|X z5kQmPET^eH62}+BpBjm5Lv8%l%^p0=0@jgE92NgslD)cyTs-i%uYR#QGThDx`;s3& zv;B)_c(r?tAo80QMA7c1gZcBvP)Yrz@H4ZoA?6LZs9x5~`;QS%HZyWkS?hgyi}VgF zQD6D{e{#lV&rvFr|8n={V;97)etd5Y zZKXmld#%RJpyU`RdG8wSKBEoT?MTT4yz&s)SYA%GJ0Hq?cnB~yoF-KHZfB37Z zs|s{z3E7S|9g!U-T2yXrc)%K~lUM_tBy5bwfi3t4(6IxijE7o{y%it(X7}{3U;hjk z=@glUDqxd@l>EzQ&%f}PsP=BnBt-Lyln-Bc@51%`i9V*QT72`DUAOFees=l_MJ=vrW_Q>HXmVY@OEb8el z#c~9w|0p@oAj|Y_@2fWnE4_6uv|G9yLR=IjEkrMgXv9RsQm*b^JaOwE{@KF-Dj{N8 zbIfs6kntN-=kLsiXnCy!y?5o#o1d*Vw@xWfyyx5-8;edv2*CZ~mICfH)r=GU5J&hb zObcr1>l^S-HHh?kDw^}iy=-090FkeDVf4a&h-7ymComHA3X5a+{{ExgukP-NN*sw5 z0H`Wr;P3tP%>Czf=lgdAFU>Z3Q8ZUcbaYsVC{*@6jlQ@tPaF#r-mVKiKQV~gSADv6 zDKJ9lga(EE@_w}>$y8%bw7T`t-~0LLCr)hFHH%=Co$%GqmYa3H*31O_=FJNF)X^hsN>3FKaND$cUeWSij?(xrP%}16}Qt08MAgZeqU*BE) z=B>|gWUxR2T-SWxeDQaFer$K=>L%4h5>z+6i$ns9)@&m~+>TOKX7S?`n}!Of{>YwC z5(3>8K=jqsZ{QS`WjLGI>Owb?rvT&c>8+_nOWQX9bItPh$;Fj_^5>6KKqc%+zsx=h zDxqd{+wRRo2`UMt2HggJ{qhaa@qMl&&1@^AUDS)`BNGm>5J!H(dnZQCL&SOV3{?&L zd<;tZ1;wYez`>vD-w6incB4W<1u~5UMlB?BBE*|_BFMW!G#aHC1 z64(d+$)DbH{@Be~y)SE-0A)a$zfs{!9E&3}gl(0|PQfcw-||bccVu$)Ept0F>6hT^ z%M*Q5m&ZKR3@{1Qcfa}bXSe?7fip|VGizh6$>q>v7%*7i!%Mf{`=l0|+fvRg{AbJF z08tcb^aIHDA4WNx_Fg5;hR&6YC=`uN1nay($l|n?^+Dza+W?kO=gRQL41!qJEH>|d z`TaW|-MT9(vCqR)gehqTniEzvKXz*S&wq7p_wJ1aNnV*vAqXHRFk?TySbj!R$S^65 z)bsL`DRPS`c4Ue*d-;YQD$l=dBGtBuXpDplE?T*k1LcKGYeiV>d*h_ z-+5%FA!?yc#(6Jx_^VT8D5$rZ;!q>*84T<(SHN#xzkYkUC20{IK}l~+80Sb#m&q7F z0qK5O`;pn&2@<+dc|@~QZoSsOf;hH5Be?)fThKgp=C{QKl<8xqn}Srycp`$V`Q)XY zbAS2TXB8l&JPL3i0h$x7fdB08-n+eVeS>!-*QvFsQxhL-JLVA@`8LBx&^?C++iy+o z{_fky2`L^nfCR+q?x(+c9Dn@$eY;YM#{Itzd$h1g$;@C$xDNjJuicz2PSujbRj}8L zvA$U)8kh1Mdh58mm)$a00^xoYu^2$Jld2yXo>bR%w6H`~@gA)mU^amL*-P+)8534E zfVn~l)Kb>szJK+%U*2sX@rOo1-MrAP8mqnd{1SEDvX3p zy(;iHr!2_O;5pn8gGiLmY_>g} z59D;Sns{GqEPy$f&jeoAT3X@;{T5Nw?cg0D0!T1cV(b3jf3$q#t80}wDs_Q~u_=TA z{>%T#L#H<`EqJG{+n1|ne$$ja8Vs!N+dleWjYi&XB(6Vmeg>b;V1ZiR{rEq6y8636 zdT3Y5xuEdmVUMq~R$ym$YT)1g_G*pOHP5|g(ATRTMh@0NM$~H>Fg7AN8a*muFLlt~ zwKTZ^sH{j8IMU~i^NpH3H`wbZwQJDi_1Pz<3>JuD`;e$QN!AxsLJ;OVtMmWEOCK@- zdFW99&6*&pYkuz3v48yMXZCM=xm~SRvgV+8b;|#1KeY+<=rM0sVMI!wCbZ^t03d}_ zzE83v%^S!;Wii`x<<0(5y}If+OSzO4Zf(^c{ICA$GmC}`5C6lgGjm4uPVeAOorTYd zz-l*#iay)f`R$vxtHp`B)>{F(ry{lAZ+Uv);J&0P#pd<#zVlf0e9shc*21g}omwq| zRhhbjz#|bIuym`GU+>#a(O$~=v~ z`wry4{mrdqeSBHZ2!h=h5_5Z5muI8-(4eFyNny(9<{3YG9xGKsp`{pYnJ6BgNMIkx zLuuz0fJq8EMkV#F_WNhB9O>wdC2=A4CDZn!fAQ}=zJ1tz@kms3N6P=?KX~kg$Ci7$ zmjolo1nw~;GTz%^lkEEN6@9Vrg9oq&Ma`&hp)C@1Po?mzty4(Se734@z5ieRqlX_o zxw%(MQ(rAaF%G6^@F2Fgga`2D8POY`+B?l~AUA4}s=z zRkqXTaIRd9HI=Ev3|*+ws!|s=6+LKXOv#d%mphk!@#yM5{~tYlmw8qRvf5;B@%V$^ z!7%kNf)ong9cr8l|MK5|v$NcmG?O4n6h#udmP$tkul}{eqKUT=`ql?XW^guu&x{8! z?bAY=_`*^JWO}23CLs@8!ES#4QE(QIrxeoM>!bM~gs4}Go$7(V_{;YhSREyUXd%Q- zE&uG9i+}#JMSbV;jOx1Pgnn1__)KlVsrC?_gbs1|g@zGztx_S>*t0k-JHTMKz$~%i z3cK5TAN+6s^JnKxy&dloB=q+ZDzEz#h5&`ctfKd?-Ts@GZ)0|Br2r|@X$0$jxY2Y% z`=*&>!vVb*(47pTA(sc>F)-4R)~rIU=BSQHjK%(A%|>5{`Y%7<>Au>E2pZ!AL4r_A zu8u$Q*RNjv@YZcnRr@73I4G(F82n%Vr%!Dzc29_1xmuBn1mgxWFT2JiF+{r`z`@lU zw<)Y$V;>JcALOc#Uqqb{8aV?bI?>oW_cGVaHAwE>`Sc(C@%FEtxNx_wXM#*LpPkDo z|0brG?AR^Ny3C$ONPq|{<{9v>e*NXO+nchg7;}kPbbSh2NZ!%-VG!qDgYYv6;w7E>dguKA@R#q- zfPJ+W#t|R^Y!acCKfQ45pZ?twa{u~?*`8!>qHtbFpr7QAjs47ML%#MU-k6Ws z0RUJ^lJ(68w!Zjh|Ma;fun<%+H7;OtX2S&7JG-+X??Toj zB?4%7X~@)5z{R0udmNoy6XVF1US$VTFSfc|Ih#4 z{ZE`eR@Xcu(F|yPlH{D9oC>NEIaXA5G&FAklRypZND%N>FMsvfm5tTLu@x$ivXh0{ zqJ*sddpkAyVd-mjOe=?}5Ai(iBbz*ZB*j%8e`v04_b1TTs;w;~vusLrrDFiG)oikN zB}|^~8`)3c7E+7w5uv8D${P+<8c5Jm&dM(b zpP*go?h{bnYeNGxyYDK6sRmKJ2d66(rDhzn9zfQ!)hB;+-`)S&KX`ftR26w`=BYS~ z!mB4y`e<3uEy#cI*H`QLg_WEO4gTr((snA^YZ_^xUw#shTPO2*Z(+X6WMw3k!a$>2 zf<&4wj++a#o~dw0*{BFSv|J`D`oN}@{EayHXTUGs_!bcRH7_?G{fmF|$u1y^jE#e% znV>t8|M4H)`>P*suWo-muO#>M3VKhrQ9TZ`n<{Q`xa19BgTt|RtWCy~!q!~Fj0kEd z&FJ=Q_w#@DPo6#_1WQ@zdv!m+z8RYKw36HVSB-@x0JtFh;hoE`eLSbdHe>}7O%e&& zHc&4~M*Gsy!q6&YohpBC?`X^l@zT7DT1Wy4G#2Ck-`;!2-*#N}p=-@P=T~npDps|c zCCgnFE?|W?ArK%qv|#+wi39mi90wkl_He=r}jYnLT?=-Lv;O=XZY>n2$bJ=eNt0HM7^O@?C4dW$Z<905enqq6lAZ z)(n5xC#frC0+g(lPP_I_dHt6kS`yWEf`bA8i~tbedtZ0)!uXlx z$uU$?dm^Vm!JmA+Y_4%$sp0`b!bNU{*%CFMT_Jv#2o!-7P$^X(zwfJFu=CX~JZoLb zN|b{A2G0+l*#!LGY!3)90h$1O_>)f_S9@w$penL1Ix%Kk`tH^3naqABNdqvpHn?~T zrc=x5bcKc_)>k@K2DY%=h&{Xm1K?W!$$EVmk^};wKCcsW^pTh z`P61UX#zlj6IE@D&-vg-@2&yr?W6$!z!(uFJ#Wv_cYO2d$DX=-NsyFQVC`L+Ntp_x zF}=G8=3^er3nh-hGlAy`wc8A)g^08T-=OITKqKpjs76sgdfLk4|Nfsn4*^gJlh?~l z(lqm3&^uF}-BDV@0Z<4~6O07j^3YSC`O@a{@=iraSOHc9!Y(Y2r&})?C7Vt~;73Zz z_}o_;2$@XOX+MaP-EDcp$(TmM8R2?Basyx%+Zn8s6rLd(?zoj0?M{Jo-4oWF*97J8c01VMXAYRR8i-=e*!@l1J`ef(jFQN}epUa&b=~ zq7Hw(mn|ZMUU*`D6!7DXuE0=(ynb7=B3Mu-)Z~tT_nLE`vv;|cGz!LCVhBoe^ROyo zPw+xaQ+-y7Du54t;?ZX|_DsYAv}12ngs!NFkml*Y*W7$fSuk<>(3C5^mkQ{$UJA-! z=Ag6&Adt{LYMK}PFYh-mjLt|YRz1z5{5J;Ig$@ds zs2xWpXTJZh?*ah1&CEb!q(y;?06+9Ep1X75p;bIm$ABYxDg`9b6c$mIr`22?dNT>k z>1@LL4(*jNdMi3K`%I1?#mRDW4sc4}>3N-bUAujG?$Pz1M z`Eggf=IjChCBdk|JB}Rt=~hIguWmfXo?Qj`H>XyN}Q8Z+JEft!pY5B zSGiIM!S3S079hQ_qfx$mtriZAfF$8U{oq+EkN?nXU$6pDA&mg4NY7`v!lDTQQ%xvE z1n{9xJ^a|QJxVN)KwCU=rJIZYIai)O%;z_g3NtnIGDL^MXsZGEpRtSF@ar0y*CI-S zyrkdjEKTouu4nKc9xvcM@vO0&eBt$s1u!w*h-XayPa#pY^yJ3D_kZ*b09fBP`9V!T z0aq%qF4eca;_RzlynsjV7KqAdDrwY*bCNKBj9gI>qQB#H_V$rFzoT~2%p`IP0|YbP z>3b~nMTK87A_&w+_;!rc{2(HdO5vh9wlq2XhHt;<(tRsZs>+S>v1qguZCU-1=Mpv+ ziW-ZAhS?>=$jBcENXR(5ZavSMi-BFwdcM(Q_fi#)GnnfVBMvfy5l2?>jEtv-XZw0We@`Kv0l9cBxkH7nMm&oIH z><}Aut+y^l1e0yD3|@YKdF~2?|B3>R-@27m|7%LmU-AhQuYZ?N;W=#sI{Gl zI2L(US^K@OYj_bz=$uid|AfRU?4@nQ*$p|k?83$8ffV#PO@axMh=?das1vZ~$i~k1 z{OMubeoDXyaRG2ypaS)VZ@%DyGmkFH`^TsxX=@M?MKJ4OR+AO+&A#Ezg!)V}f>DLHA3gq|Pd+tT+*JeV&|l933=roGkrkrjpS2GF z0gxO;3fjC$B@#nqvsWf{v8FR5mR}(gD{*f1FCBV7!nlfvPSU+33TUp50d8-dvP3}$ z_Qw>7&TWMqtS2*y6s|6+Uo=O;yg3#OAVsh!>W!n< zf5%s!_U$h{e?uyr^T*2m;kz^H20?6!V_0YH%_ac&{Xf6|(I!J2$Gc-}Ql;6aZ{9CBRTr;JT#C53GLQ>n=I^)S-Q& zNll7CX;n;HWvPg>BPbHl2y-TF>I^yjN?Ny#`+=iZLeN)eib?mpts(z)wS^54v;U>u z)iSS>R~00Jm~7m6`8n!GUiS(D5{&!nrWgzBk<+(h0O34F=%cKynTQqz-t_R1KfmVq z!s_mIRn<@tOl4#?Qve9m)PrrPk^S7b9W@_f(k_OAvwXvoB>D~|QbThk;bao!hj(?y zk(dKYOudfjwj9(ktdwtY)0|Q?WetNGjr(5s> zL%~0a5#+y#07*$T8q*Vd@yq|;_q=i!z=k4ZJILV9^!;j5$P2qMNTM3xcmL1*$Lf8O zmKBZwod+_5O*h*9n@L&5nIKJvG0S&mCbRDeBEu?-!c{WG>;qhy?vzMi3iM9fxh{{w z07o=Oo$Ir=hIFy8tr(5CHV~6N8{W^dvgjuTK?Mi|qJs6!rFwkfyZ-d1Cjlk^bHZOu zH+I}nArSrew?6N(izj&Wu2oc%T9J}OQa__yyKI6@AQ2!T#uk&FKs$|7>c}=6L3LF2UT#6(S~P$r z-n;S7A#9Ku3Gvaxk9^{mjfKTM8w$*+fN8Fsr-1b%p#T&b)SggH0>FD^`{t-K8E|K#-(hC>Xb^F`tZs>k0Sf47>N3 zKKCa_Q6ey%ED4ZPWo+x4HSJznUw`8I?|sdA|M&&xZq#zr9;X$))zP7V8XZ*bHK&O9 z49Iu>`9l&;-+(1m2@*5-u2VUvQ`9S*n!&PssU^_uPpSjG{2$RLJ?7($i?`mGfQ%)p zA9lWXt{!e??Kp$9)P*~%$mE$6jD-_B6Jvn5tz==q1^}SAqYY*J^lSvECGA+i6OSGGmX|I3z&BjBsYr|}XyPQA8Z7FcO2*sQdmpE>LH;zV zEs#=b74fgGd-%|o5f^tz5`bd?4)#B463lbq`2Nv2DWZ7aasi^q;vf8HkI3%y2Wae0 zYJirna^XJewes$Qw^l{lXPsazv@GeK59BVrm7OwwdO(g-Oat&Zeu<>%zOJc*q?BAd z{nq=%pMCnSF`|^)U~`L=N{EeG{{6FezxjJEdisgmSD=>W_+W1C4+j__+ThZ6jSO%V zgcs+u2Y9-L%P0Yeq*4n)Y#cfK@(b60?z_H1fkJGvBu8o9izXe%I+l5EG_n~0D_$7y`}dc8nZ%HTu6kDUFww`DjBI`84h&lcW9s2YaXJ3-`8j?d-#sICw~T0O1T z8)oYRK!QGUS&IrFB#H?b;W4oMdQls}PFw!`Nhx_qsOy}`pr^5_#Ky)*te*G#e|E=% zlNu|WtJFD7SsaN;t-k$*&-#(qo%ZBoUtT4WwS<6E1nyrVQc?Hm3`bgiUPa{YAWUZx zE7PmnR(2yW`EE4#CrJeL+MSo|fBdaKbd><8Ntk{%-!D-k`=oVCfqyVI(1%N;L_}yQAaD)rH2$8UYtW@W1cyS0o1A+4Y3$5O4E>c#$40vzK z!kIqj^@&`${hH1QB3#}#^RD7zzGr^eqB1|Y>vky9hlH5N02Im%aoW94?s)fK97X`z zmXJ{0+3`pyDZl5{7yYZR8$I*H-8&I$X$~q(0miwk=Ltb`#nJaFp}aQNb?XVL?)h<~ zh(H3?51lc3@V9^X${hkXlnEzfm@D4-QaE4v?whI8Gs+XuxWc=hKJnhad~$K+v~?O~ z7=JWLR8%BS;iHCG?V%6MY*_L3fbi~==KhSaq0sQ<WWURGW^ z2~s~s(EXi-u{n3SH>1-c7y+WFPN)&AT=1tKyZ@%Aj$ktrTP;MjZB%~&>TA93^&SB_#**%k}rDG(W``bp>Q~^#@61AxGsu$T7DPF z*VCZ^%$DcO1SYXf$^b7&em!|eAw(e%*66f{H_rL(4}5VA48~@A7Bpe$^vnPLk{6x- zSbhA+7*R?E%<_|_j1v+uEABtN%pNtcLc(+~xQS2z_o65ONJ`vUoq(rq{H5=`@C9e> zJt5UX)WG zsEF!n>4J}cVeONL9~_BlvV9FfAP}U2MHs#H4bOenu19d~=%`kkQl|;hUp@x-WdR@v ziBL81O#>1g#*3!{H(aA6NV%v)J%`;9G(jf;no+A@{VPPmdJ~pMCpI6y?x+9NIbZw2 zGu9<7xS&tpimgrm={&4iAQM6?HreKUja7&Sm5+p|vxbQ$K>qFr?^fcBHMIzUNW{{& zh2dHlQY;GQ0npLEjb0RUazd8=Ay4F%H%^>>a1|!|hi9R4R1eR9kUFP?*EvlI5s6{x z_5sXRB1@I6jM|;;xV$=(s%v06$OGNX*D}IQxv-9x|Hk`ndyEtcv7NWbN?@(%z|!K| z|I@Qq=svM=Y^kPNDSK*1UrhS+#agXxWXa4|+Wd^jh$JN}jVAKZ>wn~*o&KFyU9eW0 zY(J2No#eYZK;G24h{wS>Pj8SvK#~yQe}DYGo9|hkjQ2@svyUaZrLk`(*3Y`e5BTa z2tg4lLX$FxP!I}z#mVV3QwIQxCgd1%9ER43oHy6j8GAk^hi1Zj9K1OZ0!(UH6smsg zy8rY|d%o*yp0gp!$+33cvL9u|N5% z$JN4_8?p)I#)=W&n6)p1# zeLr4L`f|B&m0GPj%A=grpmQx*h@?RdY?&UA2O3XP;eg?L&v*htCB&wp<-M2w@yGA^ z+(S=Sm6&YH1VOeeR466A;LQE+_|bD$@vds4MpY~4*ro_5XdY%zTf(%%vns?Qgv(#? zAlsY{h8jS%q=gY~K6>2`eC_hrb`lMb(yD&2m&81m_+QeS<7Wf?E$ARUJW8 zxTba;Q)m6oA6|O|U_vy3Spd%GR;mg&rFzk`_P*_hFRJkVYO*fsT1iDxxuO%#YNCT) znWi}P05kCx7Go1Wlajf@h|pMb9VNuP`ZX&6f{BEM3O679{OkV_e($%xXj7R?pWrMr zq_l9DLB98+Vd6{FUABo|5doB{1pf8SkACXf6S%T(LKWc%5-86UaXOzueTuMlbTrwb zyiBWhu(LI)_)FR(gUw%CQ^u88lj@4IcfIY6XNmeg zQEygRO2*TpPFLoa-pZ4w${DMp-N!!-swcEqiSwwyfB3a8-Bg4E4Wnbra+;p^ z-3h$rJlS7wqC(((lg;0J|NZso;JRF-NbUN4Mbie5*AiByY=_{>AszwVXwkNu06ZxVTiou_mX?u;7R0Y*a7XqBkP;N&?|$H}yB^sw8J#9!gz1cx>E;SLuUd;j$l{&& zg{c`r^sqXNtrl|@VM8VFgq83&O9x!0IF@rxmnNNdFacRTO=0Z zMoNIyonlwwfNU*m2@q8BhizRHLWaE#~-}zwJ%%y>3{pO2?2pkBJRAk zHQ!1}PDTPCNuvt?{L7Di;Ocs`a`vVgu>&8I%YK;B&xtvGs7FcRmLIn3w1yy0OzbMCwDY5f8AG~_~FAm+mP>Hqez6AgPj4G@p{k@C! z{@V9Ft6sZrEY}exwN%s`BHWD7JCkloHb>YayRB>ANUzeU`!2fEhi}LRP=O$gVC&n*aBFnDqtIgic^rU4-lj*>7b4Ohep7S1K+R^L40FKr>1Mq+ z644ORhse=83-El93l)GyIl~o8V0j`)&t7YfCDerf>?D&q{s@JsB_~AGvQC zQK}LkVYIpY!neHpmU}lRBye-vs7;wNs&GxJuYS(HU;F;k>f`q;Y-}!&Q3Rlop%Otx z!$0P72f*}x0>13+7ZFtoL*Eh+38gA2Mu_VVeeOTJ8h_^XU$sG`0%*oezK(uKP(Rzg zKO_mTsXzg|<4^BC{O|!>SXEe&7^P5GJ-{Yi*!;|gGZTPcV;+Q10}JUnLl9$M{uxI0 zafX~3o&F)*oKw!I+CLk+m2#ziPX_Ol`dRvHhgc>V2)1Wf!nuZTIOL{df%Fgyt3 z2I&Fvm$y-ebi<}KjtDZ;69Q1e(w+yOIpgi``+@?PNTs$j9tZ#c6u`K`b*cXTrTc&H zN6uQr2Vs3}0c%By2mZF_Qm_@3VFcM3)*hAe_0!H|(*LBVBb_hE3++jM#(H(N#$ZK~ zkT%vIx%x-HY5XIv`-*jgwGU65npXDjRh-`J7+C6J3KTP zZF4>GUw-n!yOHh?po{TfqNOne)pb>`JpV24zTxJ_kB+O+c0D%IFWMh)@qyKM{ph*R z+WX|@lSh}JmbHq0em0xA{V#(0NZAJr=DIzof`1qzfRRhp4u_B8GI=T!>)y~(`nar}V z_$O)&swyMA zB)7EfFcS%12Qn$p8#^ih0SZO3Ub^Jay$f&qgX>0!wK_$10Dw`2QmKP0OaJ>v&wbfN z&m4c|i4`H0lwd@5BtR8g6*j90*HHc4v5BJamVQMr0;mWoiZGg9rBD>0=079612(Hq z)*ul9CikqYed)J<=moES{&~l0HLirP_F^M%>O`rangsZTcK-ypws9>K11S9v2SI>D z0tlcI;*rVPTi$!$=J>)1RT4Y#{#wTxo{hC|vdRpw)@O@EMvB)cBeSlk75oVZMMHmX z1K@f95@5%|7@Vgd<6G5UB+IcCd!G9mGsT&aojZhcr(V%r`a9F|H5sc1(ve%BA3{|~P`a{SnX zt0SqT0&0^6rKg}JW=cX#g%^~2xuVrVb^&-ox9uVOj{uZZSRqvFx1Ej;zUL=jbmavH zj@4>BYArv}BtPXPFysp85mL!{GhrS zZ3;1ffx#@Jz!vLagmGrz1Oxlw{p3y_QlK#q84BPO<8b;DqYvvF0f0bQm&?^?bjR(t zy!6WRb}d#Da;)3ixd=o_FhcQ9UUJ}(wI{B<%cI`zej14?oG~%%< zw6=d}Nk<|m0BvYV+HCEaO#l$6f=>P~22ZhTqmj}bY0vBjK$|r#;sl?${<#N^{q~Pu zdGW#3W0Dp|!r;Cdv`^|A41f161Av(JHwpxRx>k#oc-O~od)Hs$;_h=crEy!A^s8Z} z{YHSv+J-wt8ZLp*`uPwv-U(e^XV!q@ngIaN0aQlaL{GHEhD#8U00c5T)>5Ds9ZW0; zKbekMBM91@d))XF8XLm)%m$F3J1n{sr?tDI1}^pfb(WNrTTi=sY+Es)a<2c#t(fplD^2leu?nTva{NOA1 zEmp@RjH`A*wML0Zr_wFCJ?tV*p=!Bk%OAvv0BC=s>9D7rqd+;g1@_ss;ks>j1`L${A@{fPAW-@{Xg**C@#SmO^M;vNZC#<- zP}Oen#OC9l{TE+z+RyyUD+S<&0)4tm>)c6j|H4w1v!r8N4c#Y*07!_a2vFdo_4<$f z`t482i`HSMG*t@A4KuwPZ1|nkYBu{f*VTZ@?|K#Kz-O|%ft{S-TSP*EfZk@=uy_Ol z(gp>cHR@xwVH-r<^SzO|>16 zn+1n;Ci}4C$4^kzAt|B|f;J~(x#y+7`zQB)?%qciM`GJ&335jSAOzO6{MzT8@ju`6 z+|!m0O^!aiC`3&L`033{pzu{Xh`SEXm z(S(#Fs021nX$n{4$(f6Q_1oRFQBdHQ-gU=aPoAk3cGVz}A;|LvnL!}B8IvR02XY}H z<@uw$NG6*%%wzHV$28QiO>@!SVoV@_nCIhBY;Dd45T3{y;;Cmf4b$WOQ=fgr@*|8f zjw6Cn=|-tJJ%^fIU#keHz3#28t;G8S+Be@KowCF94R%ohtqI9&F%>APv08&2N0+bo z`QNxmWG8$DAsh)f0fj|7omp=dO`;R?+cz0EAOq7xm73mbBDUM*Xu`?ng zK*uH>`#dOwshB`eSC{qbX_pmR6t;#QsEJglXcZ2NBVYKhuYb{h`1%Vs6#)v-e0xNM zgrJ)6qUKbuPMN79iE)SrnNm6`1A(O!iilpgx15&!kWshV4)kQHql4{h1d_*TsEpaBPQuohzTzfApr8zve5}){fkK`qBv{ zL2Cc$EVi^T)X2T-T5_1g5K}8VWOf9aNE~CmR3F-{?)dFDz4}|f;+&1T7L9?q6XZU_ zP~HK=;eRSgixqzQp2vUn{YMveK6`Bw1fos%-z8=a$@&?sXZjj^Lo_-+m<^pe>(Rga zrkrXNg?>GcfCA9}QSuhMW$sl6u6nbbAD*6Zem43D$xnG<3eHOU@*IzzqS?}f zAsq+_1uC7aRN#Z_>p$}AhmXh$H`P)Ns8HC`-qSsdf6;xvbnB?g`6-9dfmN!pK$=T5 zd$gs0gBLOYG4AR?AR|YE%M;qR6d|BCXqrtZn%>BEGCk7JNYH6i@F;2Npm}?B;Ho#b zH{y=&Y3DCG2j$nNX~lHab(9Z7k8vzSh~;V-vH7~1CE^k^M%4~tYBWsLoW~u)h(ZXc zYg}46{rc-}e9q;&FW9?!O6~!ulO@nAF5Z9cS@6;8@7%S7l2#@YVUIs$KP?&7w&x+ z=jhTSn3u?B+&F$?ARl{dWg-42!+Xn1+R)DsgZrkY&!rYHh-$c(<0kMr`PX*?@j@O)c2 zfv|}&o$JQ7tE2*gY8TvJKlbT=_m9u{&Hr%4ZiG!mBcJ;RJ58`=%6(%cpHlh(@`kUq z8HJ#LCQ?-b-tm79U43ZR;*R~bR3t*hgu?$UbRr&5P(y+$QnWEX^Ufoe{mfghIRUVNr%sLt zw~Q)LOM2n6cD?rpUv$ZNC)U<(-!(oaB}nCFkPfJq4c3ibB>t}z&%xziT`pxppoy#& zD!III^N!6Me(rm(eA72wB9)Xxc|B|zNvP!AE)_Fwqep;Rs>Mot__}+3@BeHp?mT}o z+2jEmo^=M2TTPsYHo8ElL`ZYxQur^lqb`-H5e-|6rvJMI~UX#$(0EjSA zw77KO{(BxfUf*}+W#>pGN2i=62mrK8K+>+o(YJlYnNO}Cz4ngBmd8s#BLxD5rHj#m z40Q!k>k;SZh)|WdE^W`;k|@;@NxG zr4m&YiVJZtD~5iI#&tAZLCk&FA?QE}0>oO8sH#H`KlT6q=4~5`m#wKKiIvuVxbVhX zvEOoErp5r!Q3t7+WfT!}P`TeGpb0egeL$gw`sU~}XI}JrVjp3mHRS$9Cfhmw)MSUi z#$)jZJKicCtnpU(>=mKf|Eiy23^<&aSvqFJISI@llmOBre374L=ZzAP_I62zhltl6a+THIN+eEDAP_n$96qr@iXU4(0&7 zb-qP{V-78VKKYA^7arOU|0ESItdqs?^sB%^|6RaV#MfUTZ7Liu9z1mYb(g(x_u0F4 zkW|$vsRn4o0!l^CJ!9wJf8omKZ@vAVN9E3y-5XMQpBR`aN75NVDYQR$a-6gFx$&p@ zAsX*80gA?n$~F)pDMdA+rTVtTV>kcMcU<(}+L7HP*)H8LlEb7L068S#w$Pky$|ii0_?df5WZx0ff>@XadSmXNxh>TSb$8 z4~Oi7Uq>FdDa1&?np%BaT=Cz2?Q{2UY|<%}F4QXsFcMfxbjU+V99-=@n$qilG2DX(#jZxpgI2w$Nz; z8f_sKw!_i=5vZuE-}r-`-VG3p+Qb1IeASy<(kHlo>fZrWj^1@~HO|@!w$pe&3x=HU zp7;0`vdDs_91AJ$bG|kL5GWPl(*FA&UVHeF+h6hGbM^7OC)XG@O)&{X6+Q3V{a^c% zrR#3H<&nqh<&|BVO1K}VQRfZIFBq`P{he1_qYwd6t;FgAF3CH_bl*?>i_6~l^%sng zHr?cI#y>#-0L`JzDRX?r2iDQmOv$^}ye72$V^hLtgnxOiFGx7=og8qTT>J zcZV%e8GNXhKNk~-;ka$C?dG?uo-nC5y~z8M2myz^JG^*^pib)P#4O37V_TK}w*rt- zEv_E8`;Nz+S$pV}m!GvsRG~Qv^;9f2O5niG)o;D(Sx+9j@0MF1SY6mT!Ld|4TQ}M7 ziDJ}p;i3WSNI(b5@* zZhjCKj=tpLgG$2q)N}~j(wKw9tDkq~*=KHEcj!jBhNFeOn-i*>5NwNaHh(x@yGuAX z!4`i^SD_}VTEhC^@~uZ7z2zHTb>^@9hZml;T%C|Kt|I#>HudPgBDl}AYGRjRbV-z( zU&58*f%olpsT8S7+d~ZjaHvt=eFo@ zMD4s&XnkReiC-1mlt=9&1tyciDV(yz?FE?K0Nh7s%`bwW10(Dki#)UvT=&p0#|yhF z2*9_VS$(d>{Tit`U?@Vc5Fw|B&iXIR=Gp2;R%u_aX}om$4L9DuWADZl=kHau5@P zBSDjO3+S4)-^(QY5%5I4{-$3(eB0yakC%2$lxQS;;v9CK4y1_PJo1}AV$bR#z&-*^ z4s&S!aQTvX$?1^sBg`v1mdQG@xtCOUVr$&nd0)Z_H4*g>FAg^ z@fvf`{*|R~dF8X$)RWiUa{Jxfb$)oAzl33>GM zFMZCwUwq>$zWUtNx~`RI!9H$Q3G^-oO~i$p2zNmHd~e!#zs!M1tXd%u5fwp=@C?w8 z|LU#R+;ztC&fT&m5+>K15OFa%Iu}z;W#5Md4t(7swcXp=ot5Ksi^v?AHGoE#YYvi% z5-@4V?RcnPiMG<=1DJ{N2V$NBE2g43%M}sZlR4W!jO?;^5+pR~yfxmV)XT}uZ;=M{ zMm&oogqbd|{|J)eza1%GTe?J!ud~&$DbW9~6|63fuf6t`voG9v(ZQYTwW`Fa>=f1% z5GaWR;a6UE`uUeGU;D*dpExpES>C%@Benh|onS>Yc4J5BGZy|vlpu`;AwW`-T8NR@ zLAT>GU;OTW`rM!X&gbl3z)h*dsH$9qkCk%@eYF1H`Y_snsqy?`p7lBVVe*Pdt3z5H zz$TG15v&8O15FTq=ADN=c4%>R*BP6&ph`4mpSB_P6}RFA{|{U)T*xz$;!cMTpq)0; zI(FCT)Sp0|IQN5wBL<+<2EcfW6|bYICc=(7N(Bz*Jq`^Mt#q4!W~KCn{^a(D69B@A zjP~d4ga80(`bk7VRM`rX3$C=wC7XMf0dkfl_=>#ZH~{QN*fltASrtH=w7f80`0VEo zU3&Sxv-YeiNvCLl7%u`5P;Jz7)`6Y>;H%Gk;*mQJ-S+5?<(-PE2^sT1)U6UDoY7Dv z&_s&Wh4t#_7tY`Lz|a53ulkl3pT41_0vs{(S+jYx|CW`6BkB=K#;$$n|d9z4Qc3)y3;<|hXt68R~?MiMc3&Jh4m2m)MJyA_9R?) zp*e5${PdiPtWGJAc@6*+LQSwF@HhVSws-!OT-tT^TD`1rgn(#r{Ran~clCnn0a%c= zEf!@efd+PKKC>ZV$r2}m9%)p#>KbK_^4yA7iOXX}Yd5Z_+y0+_{(}GXjn7#?SW`5vDm16)83w0gqQdk(#l{)- zcS44R#0HRd48*@FtpW%Dfj|HgqLIK~`S2ZY{|GJZdiJIqE36O&H}%-2i^X~14|D5K z;(3hL0G%5E_zh7E3&b7Q_Ft}4xp}aoW|ARA_Xn7zF5a<@TDr*|b6wA(aw1+R6dXg@#xmjs(<_PFq?0##fv<8Xvvxi+6&=(ZcG9 zO>9o~w3&aJ)ck}%YNJK~wNgD%KYh(BFQ|X*jaOZD@!oZ*6p9gt(13Xt0C|*e6D`oD zUe_=Yojo2yZPHffPjsF!<=w)qX^M#p#_m7Sx%3r{EAh6!xbtlvUSHmM?nG4zD*!|j z@k^ZhLzzk)x&fN54)G6%gz>&fPs51%s2#sQu)C+3^a2isXdwRTm$4*-LgE{_^ejKls$X)$xQz z8xotd#hR52polg$tJ7AVUV>XjC+_^Ae|FJ-{=Z(bZ+Wy<%W70%U>($j$UpeUL-=&a zWj%H7@K#1{_1L!_f&@6O)M{0|{iApO%AXuv+I{|dtus9|ZUST|jJB;Ib1>lR^G!g> zvHSioVn`d0N_lmPJAQ+P2Ms%{!9{j>(lbWh8eTAaBe?+-8oK?}=I(EydR6Q^C}3cL z9#Afo45j6K@joRbN%h}I39)CINCHj-vniVlIZP|Am8ix$o_J>E`WwFV6<6*%bJ5s> zPR@{LT@)fAtxGz0_sTb4b>7aMCq946eR4xCEQ}_y5&}({Pf|4j5$;}k;KUO*Jm-LX z>koeAKYZTl$CXk5Rb>TYhV=v>0L4t0iXhIrQbKI z>$znUHe}qv1e-&{_@3>6?`QYH@tPv>1n#3u11Oe6Fht8@D~tq2A?Mw)Zg)RePOj-T zfS!GuZE-kw{AN`4PknWIzO0mFR-phT)o5Y)$!8XCyy3=IUU~Z7#f8mNHgR-7B}9O+ z5K<8mU3tO&uf1~b;Ro)y_kqV37j{WnKvD!WQLsD`D|C4M$l=$2-37n+Juf|Nb-Y%q zkv&173*VVd=r-LDdfR`Tmjs&KK(B_0RHLeT=g05*h4(x)+H>(HEfI}SSj$hu`fvXn zCeCBeBe*RkV}f{SZ}9(xwCL#>Gdy^ewbX;d$7VXG7-=_8iUAUUvjITX)6i=JaOvI< z%NQ10ksmCh=~pA)GO_c)hCt;V?n}n7wb_@BUUh8%$TP$>57^)FkTLCEa10bk08-K7 zXm#!Q`n5OSa>bQrE-x-jq>&sgkp5IG2p|9jK!u116R8gDSbEJX&KWNsKXmAJr7Eaa z)C6{n*EWt_ch1hoe)aob{@SlRXF@a~7zsf(=~7lf?uhB_QCxgEVqfm>$hD~LI-ns> z5@&Gomx^vZ+#78h#; zBZ0xUcIpo{cr6 z|Mq~2z9GyhA3*4B^zKsjB)Fz_|85ir)UvhNrt2SYs#SCp$?Dtx#oQ3#@3IX5f?x`+ zWVKas?9knL6se~Gx{_xW)hrW3DWmoRq zv$&v6{ag_(m5elu2yj%Yv-a-%hN~`l;DN)BJa+iM{=1id?>9VO2(h8mNC+e?IlD*r zf}wk>7R(MO)@Ww@N`Y6*70Z09J!3Z$sdiN2_piCL%JPF4TiG*7}j{$*>V@UC9g~BnGl+ zo~C9nu1~|`nsmoR04SBHfQ~(V`RRB3>>Dn-c-KypR8`xaj>W~K^9LkFBY_g&*!srq zrNuR6ju@r}))UL^Gw9U8u7c=)Yk3~gc@(x;C&F}D5sd}@)~D}&%lnRrl}k1OND0EK zbrc)VP$U>CV`Jv7;j5Dapx`UleLMR|S9qrGjd8->r{d{Dc4Fi?=@hbXDPoRBGnZ(8((ZSOHW5Cq#;1=hEV) zQk4*hkXW%yaO&dzeU>FPVsa1SMq`8Ht^$CXU_uH6{>H~{eajy_J>K!`34tP=Ggfz1 zArc58jQAUEoP=vLneA*u2pE;^EU1Ja{}}|Ikbf6QH4{zEM-@Jw|Ja5VLZF^9F<65h()F(mtUcwf@fzPH(rdT6*@=3r}N;Q)odl=p0xOM~* zscLE0okuQvdY?&&htTuA zVcKF-6Kn!VA>Q(l+kg2_ zWXyayU0DDiX;}aX1R$BT`KS;#2ykJ~18bMR;eXuri8~%!uEYt+knX2o;nd&*F$PjY zHSbKA(pVFYlj$@|HpkHW&*hOGp7!DMeL*OQDpG<7p+=Gbi13SlblY1$I3Dk~(~J8 z3jj-paA;nnRR}c>0GFAuCr0fedm31(S&sIhHjL5vjHX6mA(`WUMT3lUIy8rMjxCG# zV%7`*p*aZMrw}=7R4-w(V9cp1O{@d6aAGjE9tu_@`?cCZRj&>34ZUUW>ehAzx-aA8V@6;K1 z9O;;5yc~NKCtp=!NcrH0r0S(V!eR6=H874~v=g-bZ0{l}!Zq3<#(O{a+1pok*DpSQ zza$t9(eO@AX~Zo6h>%+WcxU!DNdJPG18`)%rBXXPlTUO6~PE3J>RN>}aNfLG=o{?5hDlChb9R=#!~D#U=DNE#)stU&=^22a6p52U);@*W%-a6wjX3E&-!rRExXh+h(IV1lTeHKh8e;+i zz(mrh5{I99=7)aymRlbzpU!?EUmkNeMQs<}8)PIEJ;q;Ug1 zQ?P}*12`Gq!T>Ap2mv&oLG%1fhK%l_WGi!U{hkINC3GxcFl_S;PD@S z=QW!^6M-jmguwPQ2pRGawTmdgbtnXBkL{Y#AQAwdel@gzpaN8gI*%v2Yy-FeB+xMl z3zfL$o=3m$7p}kU$+N5FgPRkfaMXy;UV^jSSb6%kD86$hCD8gyU(=8nXpmGooZ0O> zvfr9k+77O>00AKBAb`|SFp+o&U=|L9IA1E}gl3CPa8y1wy>{FRgAftu$mXKhd&M7Lz4#x0<(jADWGuu{ zNt<&Bo1b?HP1sX%?&yrLJ=GV!QYFCkW9k8kFd^DhxLo1;Z@lk6zU8jRHZNURJg_c> z5~HYrJCFR~1hLz|w$kyqD%14VVqn@oZRY_X>Nc=+D4B6DhIRR&oYP6+v=Pce@9DXg@E_yCsg-|OZfq?M#zr5`i-utv%xfsL_DJy`gb*TBK|I)8@PG%ot zt8RWzRaMLH2%3C$CQ7csz!1A--5$nn-ZM8Rrzg3nA!c*OX4c|{M9-hm{F>FdBVqb$ z-gbnobeMoRCO3MQPs6;?&9M%IWpxr|Z4p;AYj%8q01AzEOh^ZTVQUxDzeNkL4>zj7%+W^frLh8P{{^QR<6WpgRgqCXM+aL2y~cS^E=D_jH)%oAixF{An$5o z1MobS{0C8!RHV7y-U1+fR9^FBsFW3!&Y-ic;e_TxEml6d#s8MF20>w#sYPY$RPrbk z!X_;(ukOG0+It>6`skG}IBN-Ua*|FF=(PTOz9nkjY5)|GK-~0-?-hUDt=F}}6Dkyd z)TZpTG2}H7A&e3K*VVWG3l zdBxY$S$##BdVF{7I@DSFdmgd~nzM->>T<7=-hrC6*RVEya+&c%%))fsAf3RhWV0DW z9<*%9_%D)#h6r(U35L3IT+EcvzC>@YTXKXw_3~AD=}o}p*CkVu=l-^z^L7l(9jP7T z9hzYYL?y%#xmz5(;)7S?8-M9D_pGfiR=9EUvIdnbQ7|U{L=fO=%?Q!Hy2+z5qmZiAS?il_*;(b&je(z&XREzM&5GBbr{haoOTf6QhIl(8DO{6 z?~Qk^+$maln77rkW^~TfnyTqQko9n`$rqaw!OVjlL*gEllEL>QA3M{s2oZ!zA6Xl9 zym$zi)hhX(q$S%rq(ogAKn6@;^(ty^GEmJQLM&E8g6hMv0V&6m{5VS7Gi_51y z^vLFCt~+$zMZ2H9XQh@(p^-*T9z2LCmAW*ieL(};?u-(ddSQ3Jj%@B4|^W6X(4`OTj z)J7_-0XK+5g`c|Xkstr{J8pjX;^o~3Ybhm;c$7YO9$_t9K!6A;@k*HGYVaJ`URiP@f&$fGs9OB~?28CCwx%uX5plX2(%cNeKRwRQDa%yu zJkV4~qzFN*sHb4<#;?C>_kVir75f2{RO6~6-f+s&M!a~}8!3`q5&$5Z$Jx=C9ZNS@ z`T034T|lmYLiUobeWGYhpagi|$8LYe2Tp8M&%yDclu8Moo=x`@H#B(t7??SVeTiIM z?puTR7MHO+9hw@aBN1s^xwD0^*oc!}y4}uv>0x^%eqI?-2MeCMVEe6TFB5iunE`Y< z5|wVRZ<)cg(CS@c+uf4~Q0IVsDB4ONb5=y#3lole22JzB*n8wL+%;Kw z zy({W@(gYNytM>lnb>()#h*2M{R^;@Ftfu6s#V#Kqnx_?@VreSYtM|@d36I*cE(94GF?WLKl{?zcqP8T%Q3V!h~?Ndd6dq zt$p&E8%{fC_2ScZPl#lLPMKkGLLDO%Y=#D{^Zn)#jgxVLF7LA1BW|skmK^sBKXq&6i?Primgh*7C2zIQYTg>(06a$TiF;8Ga7r^s zubR{a^nOPo1By$`J-fM3EuWx(sNd25@_27Cf2~qRmus>FN7$W?ajl^29JIpr!(KA-qq{+4dEI{2#pr_Eg9F0XRJg-0AXZAMvNAStwg#y&QqTVm$6e)pn- z4o75;wm5~dQOA5hiEaqAx&BN9prIi-pQ}fnRGrf-Efkt2)Z#bXC4SwENr%TqciHK#)G%G51(;A~6DOuY{uho)&IO7HJa1_wF;zLD?4Zs!c zgFhkA5gS7`A`;RjER0r8f9RpLPhb0`U1#mM;NT8LfV3rN33P1$Xa$5mi@c<ILZpwY=~;$Y~gwT(@&0W?yQZjT$?ZR9%GuBPhu_G+G4&~pZOh@;3Y ze(F;w!Yei$_hw;8y|q~*HqpXOZQ31-`qWvZTH$zMcU@PXzWUIU_4*4hJ$)6iR;qh> zb(Id+h9jD``U(X#6r}~G)&T9;QG>=jo48fLtn6=RL;w)NhN4Y@O5h(|d*^?7>s|LA zyL`u<1M6~Pf>vW>vKs*VJqtrbmnQ0Z=W?RMd<#;luUaN)5`sq7e>9WC;4)hi5YKN~ zLf26Dg6x&B(cA`5-fvD#A0wO>V@vKB_MIR!an2KVFCHlCyupW#27K1Dw{3Imh!OjS z!>0NIG!S#)At|i_vx+45I57${YjS6|d{jrP7TElOP2DH}CWI1KSC)6&aO3^g9=`j+ zOHX^&%A%5LCPwR`4S-$ZnV@>5bO`>4o08f+0sRllll>LKx|CRnCuRL}@4E5r?-z3Q z1=aF+Qr8Mc;ktI*yn)y|eaX}n3LJJ}7Oa+8<69CtqW5PsOGHK+7!WNyTV=v^ruN$a z`rg&e;blLjQPBdr;sCr79zM|ao>!DV*o!!ejCY z{onW6=YI1`&K(1hgsK|&{dfpv|E}foEgHA&q12m!GEa6Y0Um-o`pvt~aJ9%9#*E!b zArYxfg-ezA>^%?vm$x6j@5u|651eyC)@DAM-bRW*0PSsRk)zaD)o74d+H_g}W5kXY ztm)>O-vY#Xj`ZofD1dYGD0eo8WN{8KXKQ5VbcDe-x$){g>nGW7r^3W_@2MgUAlkW% zZNYE4Lj%C%op=rITw0;EW2gc14Xa20cj_TbRGzc#Om|M~fa1HlG4_D{T@YO3>>yE4 zkfl!mxoeUx2ohqQDp)wMsrG&Px-Z}J)YBI~=fM7PRoBxVwA9_{;F7UE77DT6I!W}k z)q31hWVxeK>NDIWNFjv4cYN&de}2akPs$gKcI{uUCkO&swiR=_V3TS%5Nz3IQ$$LR zwfDadH*bC@HGAWVRX%*p`|oBAv+i9dX2lmwIh7LDvw7f!IxFOoblat@KoXKCBzRJ! z`|ayLeC___eHTk1Yo+DxcG_aLe-*W6 z^zAz8#BE$~QSBN-3^ir|S4&8cymu+uC-OFclz!l<_V&BrcSDH8`~agp$zs=KX=lni z-PEmw5IuYV9=jjL{d;w^{AJ9GyV=@OVxsZO7raZvze!H9EfxHI&JZ-+IbG2Wz(v{> zbR&s}K`Ye?q1L6q@oDQDi+^$T?MLM^FL=%w3y5n{js*??iN;M5*tW}c6lXA5lTO!2 zgBJtWi_xwSb$oA4Py;Rr{LEbs|KR_)@eB8yyW`*m6SXM;ny~!1Zf!a%?4P|grVu3B z^vxs-iE{sD%jcN7$PO`}cnN)r0alo}o4d|WGy&l2*~tMOm2|gwKkyDYC}}&Y!(|FX zrnwq`sSdr`lW9_V>;cSrUM8=EmiB~KK;I*XKAALun_s$D$^I0FLHOV5RQfM=TL6<( z6OMfr`|kl-ROUL*jKpAxQ@Ok=cl3h2fx{x-Ky4Nyx}Rbma`xBm3z|M}sgxcod^ zTHUN`iluC8wEj5T2n*__1xaqN{-t?C1Kk}J9 ziw9mXsm7bCW-mIn{E7Ix-8F#mF_l(4P>CL3TM#Cn2;?GJ8% zYVT&fIMcPw7G%=0Z02Pvv;kM}2ZmMa_qVk| zOzL;~5Ftbyh?&m!NL?2a&iZzVXoLwQB*t3ou^9XT;{=fA~WD_uu}!3wJC}q(XtC5)G{V z5rd3bP)>A*6ZN-27+mk8=F$1Tcyon}me=9VAe9cCy9%Ll! z?1Ik9NkjtB{&x6n6X%q3-SugdEe00_l!4Pn);{*x!wWm)CFkrXL@5;t5M`?-2*h^(7ZVVmG0@xJdD3Cq<)&KU)Nkchg&+ae z3CESV_t>#F|K82N`{8PF=Zk4+^~A=eKw&UtD8MVA=lp_dX+@}zc%nXgqV8Q>^wow9!~idYKsgBi?u07l1&NDzLA zL}|=q7i_mv-i_XkK=T_l835b?~#^jpdg(T=fK+C*Oso@gQ6I?epB<?yLe!5)_AJE+)v5TS6Kl$+A75DA2j zr4B_6yGk;wqyzWH@L-gX{XI$FOFrmue|_eJ7YkPC1WYR^@gJaa40L)updh*^@{(Yy zLZ4M2BD_AYF>raGKi`m)oq*OSHGRn#+zB;4fH$|@4U9hnX`Ag)Qqd?G4kqepEY4F9 z$A8>ccGA2cyxgUWgje#~L*&&~`IwbV)VDZPLU3f^_6N6IbBQJnXGE$=Dz!XXJ^M@d zocP4&4)5AuU2^8$8j&PbqWgSF50m53*)dskjxQ^HqjTb})3gMdC|Ih*eQPKF>w6CW z>IYy0FI`?;n$&f(NYTEY;{Kd~Wm6Hqt~HX}8D0ng5Zzv*I@7S#mN8F(s{z>OvqJRr z8^EyI7$Sxzp>d^Tci^$g4JqR+g?Sy~q)T)y_aqnp?bikXc-fDUZ`#gAMV6!HyEK5| zrDMYmZfmZSIbST=MWpx=3_}f5#}tL1Pcx|j2}CO$my|FFzr>3$eaXzJua{&hv5^6) z{cP;NM1-Ui;Zh}qd>W76^-ZrB{lGte(P`CKqiFJH7=?(SC>Dr_6!T%G4y1vY!l z*>qj$xR?M#ya-d3L<0szjvZ379k_W6;_Od1qZCP8Sys?1bd;M)^pR@R^{ktXr zq!eJzD-edYMjvyMLZ{X$@0O&0A`vE}HVIZL@!00Zul&(<@A#7=N9yM;?$}vN zea@A2AG8*KPYLjhLbf5(;WXnb(Wc9pW@NYE+Fl=9f7qxu94_SYJ-|yok(iT{Rtj)& zIe8ExSKL3?PS{YUD@9|;74-ka%YIC18J44f{L@{E8B^!Emjmhy>n|_PzS{L@^g{HE zT>d8jV3V}duOT)9?@kHozsS1Rk1zvEp8f)zeKEge^LhX@+JKO9G$!15YKQ#NxBsJq z|N5U?z6&cM<+!S%_MnI-AkY9*V-n#$0Duk|*;@Qt`+Vbymk66m)k2H`KK{jfe&Kx& zJ@E8_{k!)+S?_G*{=h8Ip!@wTGe$8_BoFnxqR*lLvmZtK_|**61|mK3Q$Do*$mW4C zlh5oofSIWj;kR5un)F8%al;Jm8XGCs0L%t}Sn>W0+W@Rv<8RUi&@of+DP6{|HP#-0=&pILtc_^V|T6 zs+rve;EzkR^PXHh4TxYSEIWjsPE}^T0kqXjy$6KmAg~(%0Psj)#T^~RqXE`4b+I9*!_=G-StcVC z1_LHN4YLqMyLmU3ZUfWz#Exq~>7Yh}i6rw4Z%MIw000mwAw)bC#9$MUtpWbVZ2%$b z2ghGeU522o+W=-#qUpdQeM5&h06jJU2=)NJ0q`FPiii}+5sFpulsIdF@MH z_UwHlP>TvZRlw4k2YJ~dn*l~#t`gApA<6o@wUSEw7rvNxA!bjXo}n!wI-<}J)b-BnH-H)Y zmbd)|0D?sVPrm`=nfp9g|5Pt@oKlde#%2A`x`J6$Ij^U@gTY&|jU+_+w7+M&0VIw> zNRSKLeq?Y{VFeKlz;NX@XQyVG=DI>fiU@X&>T2VT<;k6|`ML|f^PfELbU>79EaIn3 znytnCa{6_7wiyaJ7s zjm~JWg<2xE|7AxA03Z=INL7`1Y;*lB@4oh}e>gfOE??TQS}P?90WdmDSBJnr@VDJ? zgbB|)-LmQ8X>?_P?mmn4p{Vz*OFPYY9tWGacC{i4+i$g4Jp=-n?%~-TjYW zb@1PO%Ztt#jW#3+fdZj&d)T^p?N36k2SmxDA60rwLaL>bRW$ih1y%4FGXQaU3N5mb?%`a1J0~DW&LWb@M>@zI!<<~5!=-8vZ>Q|7M zfzfK5c6~Z?5wE~Jm>d)&Jo&>*7)ikLAoOB);{;5P=9DfWYFMS%E>j)b!uk03arF5VasF4t1-(WzUiTLeYZ2QG$|oQ-sDJ&<-t_5dx3`BvDN`7VzlC+S}f9(}zAS z)y@~g%A%4Jf(pPr28UtcqQ;+wGX4h8%vY3d{>9#QQ03Qf(o4?O0SXbV!6!O{b-X|F)u zBAH_PrJF%V&zrMo1|Jwxezr^vn#Ht!wxa*1Y#(U1I2_;BGk(@`=HvP0&G`0ay{7Gm|>jfxe{f&!TtERo;%(twx_8-6F z&NB(>_2;eF0u7L-MP&x?MnP3zG+K1eGD!@ijXAGm>Ld$ zYc_zMW8q?KxE$xZ0hk`}bX3YXJiSozTthL_us3Q?8<>6rXd;1mHzIogACcVvz+ih? zwOt{Cs>djn#qr6J+b=xu)W3i2XH@_s)j}mAJ!pUXi$;s`nmue1O#mx|LytZF zj(6XF^`Skm^8#92tZPLAc`~?mI*h}kBb^!N{!Zvq>Unl|zy<(aqQLZJn5b*s;_mL? zfdm2jcQLU65VY_CG+pw61DzW{D(Qtbwh$3Q0aUA?|FP!#Z2*R<`3<1c2hhSIF>-gC zlsuZHlb&rh_TJ+P6~rM1qC!2uzUp9A*|}hyT7UHG7z#?HrI|4+~V`j=f)0n}1033F&d#b_ID05yPAss^zj;Lwv#{=)yf^$WM` z9_@Y(R6?m*w@4;~zpypAc6`V2n@fzL4R^r?5I#KR2C#jKw|-1WD&M6r_f|l^qDI3F zfYlJ+0HB!Kb}Xtf^?0L$m?wXThhU}~z(5Va6y-1ao>2RfP%^CFes3Y`j}XyJux3p5 z8+W0!ZuR=TT;CE;B;`SjaP5rqFw$b|3?{Ea;2)k~V!P&V963DD72kqH1q-Qj`)Ssv zrwur{)QPQLP@o7DRwFU0pT;9!{OT9c>tA=p#rt+`DzyN(AZP?_$Q}V97$g-TY!YoM zwOCchf&TClhu`smBaducx?{)gNnH~FqTs&Xh!MZB`*)_qAXW*#trGj|^q8ORQ&QaA zx?UlM$n3%WgVRBX<@asgg%aiaP`rD2<1$SsvcZOVJd#s^+w%@5 zFs6@xDTfAbpJF#QOTPA&9j|Z2iy3)IhYo2AZ@U>Zzr(M0_8WlHpAaC}N9c?8ISfJK z8h~CaiZwlW187-fB8I?j002(bX4lb;e64DOE0IDe7Ag=YzPx|&?r;0LbH4R!p1T)d zB-MftRwmRMfCN+kwNfNd0Dk_V$KUn`cYXFtdtmi^F%ls40eWBpd_CEmfjG+-J%5g~ zpA_8AkX*<=7jF{Tm@mlrHHFfOR7^14W}_zvF9?=PU|8u@ap-(mJ%7 zB}W1E-2iOQ5n-qX(9-!7VEygTy7E(oUX4gl5`&6{+I>2gVRi$EoM^`GCf=Wt%ze!` z+!frpZMdWBt{j=z0dCff&?v-dZl}Oow?u@Bq@o2eUW8{hj@)$Rg-5^dpMS-R&pIFg zP%5efHOh=4stFbaK1P#wedyNr{KYdz>B8~q!CEOGYcv8#Nd4iK5Y=YfS!?umrY-=) zw5z_;8o)ib-U^iRhyq_Z4mH?va!;)S02>{x;hU^dVK?0_mxM(xBDy ziMo;E2y8nK=fZzGV5ZSHgm6W*i!H^q*x2}75Ei3=l10T2lH9f%03`MRkB^sc0G3L@ zVhT%-4ZvrtiT4Z1JnZ`qg&pfx7EsFiLlUMt#uBbIlj8QLnWNZPc(7Ret|A~3Kty7( z5_J5oo%rQ%{s;TM;~TFyeN@#-jnJSxp{fAjqc`32+kbHXZ4d1o?Y*F`P}KmUg4W{C zm3qhGpT+|DdjKLT_pm3|CuwLqcmp7NRN*W(fKF&Y`7H>HjgFn2iOIt0xenKA4_q?w z0p`#|oMr>?<|VTM1X_OS1`u^zO0J z@t&Li;u^8MdeOqpm1DB1VI&e9KO_6|XHvo%umk@vJm`Z3_YH9ThQQweC?MW$r_VoQ z>dCy)jwoj9*WZp^b=)k*jTjAO!hwa9Ypv(8c_{&Cw#sPAwG)Gnn-$2Xpj+*Bct~6f zYIrpZX9EpPjx*&U8$gEphuD}?VgtzZO+m!45FeN!D7814Lxu-^Ouqq?SZQXv0YHN9 zJJw9L0XWZ;%&%arxEnyz5M>yjZ+VNYG3G9A87>eJDI}~$0yiFBTEFe<|K7g;>sy|? zx;XmC=kEEP_dNW-(X)5#J5z#E8nVqY_@rcio4?-P3iE}8xANqPv#y@VpOTW<5)AXr z%JQL<(5qNUpTMumZUFWof5!Pb%$1@Iz*{-)^G+mivddjkzvdy~2B7-dTD2We$0swp zziVFB0uxJUp$%oCdjp6Dg0+P3Q1Ed(X0rkKszj=0X39c^lm7J>yZa&!%4KI zvQ<&_9eQg-{-7< zCU*bOVH>W8{OchcJ_?a+O<5<^k1-0|xOTcE(^m0~AY zG}t`S>)$Gry!9B}9J3OllP)DfujHqw=?KWH>m*9l`gW=m#Hd#WERLIa;sj`ZoYVuX z2MyEz0o(^b{*%4%3~X7qub6tktF2vH`PF4&v9b%*R)_#Xh?)QhmiC`{-2xa}xV<9l0mG>k(F45tK$lwQ_m; zDwlo`v592zS!V)aNJG+7w6RacYi)*F@XSm811@WY9rQlDGiHRSrT*1b>&5#tIqw5&9KJAr0g>JM$$A&-Wj6 z^~n=8Jgp~=jpGr*ruPZ}xc?>|-+4u>?o-w>=o-|CSta2C(bVoE^?@m@0{~&D;Gy=ieCw5pqw16wp6Z4_sj!MG|;YK z#^DWsSRl6nn4kI~MSdd%EF-A_l$(?6^Z8q$Lu{Mu!Xaj3Kyl)a+#PN!hq*l{I^*tz zOC=@#XQcS>$-FE-VAeuwa+I0Q#K7I`ZaAfw%oR8ow2R~`=hY%RFR0U0wLMqzl>W;E zpr(}xEV&ZqwH#3f@fq?o0G3)yTbG*O5D2Ng*c_p8E3|Q&TkZi~~lfGXr5d5Q!=2eFbDw=IM zak6;cQS1n{$r-`(ROQBW-fq;L<2^jXl6MJYCbP_8Py`Uz&V?@+P7`-_F!SQ{Wt6&M zHV;VfYk`Wz-sgP)-dlp_?MfKO=21%OAFKiBf=!@*pLhDrr8|lgdx$$O9PdT%p=tU! zdO*oK^f4jqh!XEHEgli^$p2)JPdI+-6-X(j?f2&TnbC4~=gV^hY^$~=CKp37pHD?} z?UB*f9B!!GcUzIMAxAkH2VBH4V4IMQAtEc@PS;^?IKRwrIX9P`nQEQp7@uDr-r)ex2naqMxU5%$L(;iYY zKrWlI#krR06_jm0z$r_VYxE*m2K$_UpidK+`W+LNk#7dkyY>rAh-0}wI}11aMv=AQ z_0A?jgT9Neow3yK&0StE^x9=V2-wb)?^p;CwiCp(y%`K`i%S5sYvF~Vd9%|3UjSW& z2nq;YJE_IDpucq@uvAthlCK_#0s>50n{1&lEApq_z$JXoeh4xcHhTvB%9vs}a_@9(#X0cAE;U4Z| zy~tR=tTT|F5W7{{Y|(e^bij_z>sUK*eG!#b0jzjzx1|CXaXU5WZr;8*&JZu*xk+N= z`=Wi~Z-o^{z~W3xP$q?p0stambwt3#TbP`q_F_6&9ubHsIb8~Boq9O>Ic<~8nhSa} z(F3-#x1(>dtRG_RV}LoxyVxc1MJ|?zic#yoy_H&ch*0?Clq48Cp!DsoBNURyBdmg-SdwzF=1EH+1$Q!jcw?h^F@_{mxFG>zC# z6VEsqgxp&t9MfjK7()I`-&`d$O1@#5$IQB#lJO$~Mz@UL(Pv(nGZsyvI5R34sDpt4 z^8!p9209Jzgl=SxQ<)S z^tq6saRn>XnV@VV;Z!Owo{u5yl_TSuQ6mC^QV7n$oZ;(kOK9p*fCAgpfo?7YM03hp z76ao*->iD??zE+m*5lR%r$zlJcR!(GixUBwbcX@j(BL+Epz;vQ52~LMg6i#59{##; z)7}C_VVme8)0DYNKOCiC|1Ct3dk~b+I+5x>e?Ig@L)rF|AC&zM!o8pT|K?5P0VM^v zOg-FH8--TpP8yckJp{4G;(i~Atg+^sVBKOaxcadw;^vHF6RZv_}wCvy-7zWoM z!9#Gj!QBb&?hxEv26uOtKyXNcySqEV-CZwN>c00r-r03(_s8nmpXcmVtGgT7?~Qtu zELHar`hqFgf^#zne!KHpkg`dRMv4dFwzF&FCyETJnBC9nWuBNL?9jTCL~)428lJI) zEo8oPPTd>C1(chZn{{wT;81*?NTQU54G&|mxk3pgG@hn9TM!&;mA#BF2e;j3t^ey# zqlS#iuXZFJHE8^A_fKK#)IU7rpJ7xgw}FSL_$GT+3$Xxq$!XUSSR9Vzp+_T`>|vNH!upu*cY$0L{j-N}qA8!g$9 z(mqqfUz#Vk0Q>-8580sGFCNU{6!4D#0E+630)sLV#4s?7;JVuP`v(da7MeuH1lFN@ zo6}JRqO;x9D6gb&p8-am;O5m;WVh&|9a7x=&Y9X~rt|wI|EN7I1gmi+WV(zMtnvI{ zog6Q?6IL~nfqroXHbwf;nGi(`K;6ux)~ula(48oTDP_pFYep)8J}OeY&J`GPXs3G` z_v{%9oBX2B<#O@hfv&06N`O`t9@WL0g%bnH7xMTyD>h7=Ps5V#FKZ|TAFV90K-YF< z-}V71+7%Ut&5QNDNV=nagJ*Y*R{Oc1epDs!XWPY(JH>{!h8Gkpi-{VL-CjoTocEZF zWxi|!>zg18`X*K)N zU0fHi2OU{TziX(ktyaJttYBAb_nx(YMS;to_jHFR$MsioqNfhd$?*2)sZ?2d5>22h zZ<2i3#L%hL?4slJbDT%NR4m3^tMCeMfgD`^>I18GFh3vbt5C^$G}f;{)I7w0C${xd zq>*>upp@&@+Fy}!sY8n5HYWYd3T!c;6rn)zqm0XFz%g8^^=*0%vwUX zzh5i4a)A$`z*{>ihT;@1({11H#uV87yR@knMp?^yQuG<(P1M+jZHc_!ZkKDfkpWi`6 zuhC?n=}&N; z9X!7}6TKuMGoh4rmat6Apyd*OH$ru3C&?)^dTQS-M~1R}#NP2UKC2>OCJJv#C>&f! z$vMjsu>jHx_=I1M#j)wiC0`#fjR_q_#w)ZNw~~#}*(lx11*ak-57rkhd4HmOfr&YrBrz5DfTUnDS*+Vq-X=553!Fp=i1 z^m)~3+j5g9$U`f%aqo}wRn_7;ljF}qH5w2C{d)n}ed{H?S)GwUk1DlxSU}-ABc*=r-@ijnvK*QL=tq5x3>myC; z#%_%ab*B+ig|vF#ta9X2vx582;=~g&&&ps)N(Dz$8tQTp4GzkpP#FukRr@zG6CWyo z;-ncPiA_N|GFOs|WH8^1Sv|Ubc@jx-k7YQKjam=#Oksq$qnw{Ba?ON0y)hi0N(SY| z(5>reFCRZW;r&gBlZK;{6&fCvTMFAPdiq!A2??SL&mnD+@iy2Q_Kt2ho%QrnQj4%v zKTqeAn-J<%1?QFhj(YYmXR3d(70}sIx3-@kTwCl%p98S~g8;G&(Da%wDxdK*p~7dL z6p62Hm>88OjAk|K%An$HZUc|~Kert-GVs~V=~RK7)X%?Hx~|4R;R3g^ zcq#C&T<+eT+7zJ@Q|;6+7atDnCBcC^Q|#It8rF`NPaHp47Tp(-gcN>=yukxK zGbKdH50@?II)Guma$ z$(URKwuel)jgrm0Ek1v$sdpIqY4U|fmApI2t6P?@I68KG`~2V8c&^kLC2^49TD0X} zYs`Ex<=qn3OB=3#-J9@J*A7_cUf*5qenjWv% zm3$&OV?RF4YdiX9u+w_2P54w251JkUqItyKP;TN(D+5ut9i_%3^}HGS8@@Djtf!p> z5A&Tj(0?`DxDtMEJHm9s<`y}Tp>(J=h(~;pJ<0}QRWu<^H^YwHCq()yIvfwKVrf2= z72groBZ5r@t<3DkVD~HKHxBx+5E{l6wSsg*vi*j2<@cE+dintI+egcT!J$%q$+hS{ z7&QFO7d~b~kzZ?mgh&fYray*X*tB&*x2277B@XwAo#kd}&X1_xuFsmbja>@v=H;7( z-VHj14oJ;VPw2yMEW}h|3Cxj$Tkajy#AYQSk_M+;_1LY?xxJ>(9I3+axJe)OZz)^e z6Fy|w30s1FS9&*9I2l>hO$(D(XYE{(kZPl*;;N3WIlv7^RoHbxHCgzJht{CpBaNEgHcF^8fdjN0_&VJeZgyl9M`o)y2+UB7ofTC8GnHw%F)dW zbxG#|9clLdVX;PJ7}asyZ`|li8or)*R>xTHZLlax0H_(D$d)AoyLw`;JtzJwr*Cwx z9C~~TRdJiFoS8O7+nB4b_E#jOwHqI+`q_TN*_b~)Rlk_FVP4Ex>U^oE21Wn5jOyHv zGey62$~+#nHD}i2E3JQ>+mFdRO#SvdRSD5bME@v+=7>EUy9ocA6cGDs&`;)9-E>1F z6Y;j$P*pLuYNV@9=yEYfN14sjFKOA1;dWXmzWk_mN0ndZ_77!}8h?B;XkyUfTZsx| z=CyJk%q>}&HC?q^@_`W4;mZ6Lgq?QH|E1V&(K9^NcFfL+tnW&w0@xHCgB!CkO|QZh zFy&k&pm7+T$veGWcUyTD!G=GAw}8PnUp)O5qwud21S=QMox~qmq*Mg23I_`dDf)V= z#pSIJM=2C+Fgx?-tj!&|Cl6D#E(0EX2di;mJRZKPvU7F#z^jE$sPc(84hWG128wUM zK+?s7N?V;Zk$Ibh);`0Hi>NXnIhMbF5nPQq)HX#UhV-I?z>u<~SFy$<@O1mZf8f zp?YlcrUDEraTd+qUOT!dVW?wMx-J)ChG(BnY>*2=+>ZsDjWk2w^h_>ILkuPzCem`> zsg{RPrFNIHdm^cu-Ej!ln`Ot@pBMd=g8M;)kWCj$szO5Wi{v-bY6S=l7L-30veHWV zD?;AJfFPfjf(AS#QY@tOj+4B=`ZiNV>NcNy9HlkD5Gg`J#t^_{oldU_OGiGVhIoW# z@_r@oqr8J60gM?+&UHpB_^XvT7vbLQ%&g)zu>K{xC;UpRW*NIWg5Tm7tiC8DtP5Ta zt5`v6L;1w+;x&QPk>znzJ8|6aHhx|y79TLG75#+8?loRB4*Fa)Ag{Se9rCPLuw(N& z)pVn+(h}?^ls)ESC!9Q^*TiLGyom!a2^Z7b$AJs!9lq$>8yj7n9L^G)E$+M+y znMEUmsare^Tl~vpV zoLH_f>(81QZln=%3f4d(ag2 zs&J=e^gkks$u6t3IzEfAVTMBhAb=uL0L{`)u@e;BUJ>Xh_sFx8BTCp{#T`k6+Y(Ua z!A9^f&qlnpw+x@&(}mYV5UmrXCWc|k2Mxa~)SdJ2nOxdD9=W(|lw#AM=}5iujclz? zfR?t0r4$@sX#e9z$(_EEuVK}_2mf-dWQlsEAPQXz0G{hCMFK;o*qUY+YopUNU#0Rb zc@)^Ykq3NjHBD0>-_TYmP5A^@*u<(nzR!vN63f}Y-mGZ?1EnD<{fo*ff!%0(MNB3B zO@>h_$O9WJn)kwQ{M3t1Db$+Xj&P2=hlxP@35;MtL{C?9iU#g>s8|*#(xWuV>Jq{X zcOC=e@k2r3qulI`*ShCD3#(ftJ`Nm88RCzw)_IhAt-N}C$JtLj%$an9ZYl$+&;;py|&DuHu$`F!B zMHYL$fMRrQwwH>|_F};^@JsQfQ4vyCR*`vA>ryQ)^4Wkn zvRU)3U=zrW-SAgmUQ$4<)uFc7dW1{DA0`4>jrrGUc?FIoTklu?B%bzZ-`_ zVeWof2~YE=XgGIzv5^ZVFV8V&aMURBceH<%ZRqypZFuj{p$bpsjwhf}76k!6P zo!rPy;+l1_-a#s6zoSML7glo9p(Sw_(Ns^JwWC?2NsZuqk(B+a8*qDiZ7*6dp=?p- zp~knfV5l~jXW)-h#X;@IW}&Lq))LVY;xN84*cFXS^UvFCU}my$cpBSVQp`myDA` z4UDe**V-AHkNP)Bk_PC{zRZVBnw)YKRcYP`Rb3d~6?xE~HqnCjIK+~KY` zvTyR(Pxaf_(oVZoV5L`FaikB>^zbhAyi^(x^L6E9?<{-BQ>si+J(NRh#ASiHcLQGg zYU)zIpFhXb)t9ObHkmV2L}uepzm=w;O{zUcvbyPnMDco)ny!aSAG02Ys@uYo5eN{? zPewdK1vRdICMRMHEShc>N6sgw@FgVd@W>MuB}W45pj+tKi5*73VhmNRX{*8k6lqDx zi3Pth+wI0qm0g;0DjNm`Sl`nT;vA(fBAeSu$NYhN`dtl!ofq|xdwqfI-0RS9@>k7Q zbr8XD*E$BJ?z5TUyGql!IJvy!(yrrQ%TTF%TB(leliX=x=n>w1sd{NS!Uq6+g$vJj z4a3M&&!#_BlUW3#Vc!qtGOZq%6D@tKZ#gQ)b4=LK^|TSq17%bEEN&UH?Xjbibu~fa zvsm*`bD>aN-oN=Vxhe*CEbaI~w1$FghG>ahg^V8K#qv$-<=>KU_2!tQ7>uLpT+s5q zld$oZCa8B9O;CHGsT@Fgc=t)s_QY{(o82&$Y7U#NMWSULCo8xy_L@so^NpbIpX3u^ z?!IM{1X@JuQHz|}pQm`sxu(nhF+fF@>^POC;-M}(`->c^b!ivRJvNV@P~i`0$ju8( z(E~l30kA!^B=%X@u0zKCyC@6ClIu75Hi-JiudMb|MbLn|7&oPUdZo0?-Ujre4Auo2|`RKaUxcox(NexehD8q(_uqn zANHne`WctpQ(%14yN^{K_QT}F@rTzuT+|=xwiu5ixbw{2VuJLZ4M$TyYC%GO&2@5Wel$Y9s5=PK`QLYqWoAV^mYqG(*l)kN(}^E>XsQKc z#qneds%8N?aD(4616DMK4@R5sYHm-d9)d;)kl9ro7Hst+( zr0ay)b9Ktl022q!hCp^nlGi$UnJ)k^M<(t*bmfFj3Pw(Io&&UUh(Zk|O6qOizShKK zNAM3D-SGU1G^>eG-e|`n*||k=bCAy5*a|*a2^S{bcL|fqY1Mg{>DgaO zlz~Q`17mJGifeFpSMpvwO0)!p~@$oeh)ps1}I`miR^sww>AH@+Fs%V zgOsV-lJ;;xENsFSesc57K=n8E56e%`GP4Kzv-H`Nt!3ot!$-XKlT8`bL{pW##7NUJ zfy%guS97&Ss8_ba@h;#U6!-8Pr+`e`8!w23sbz1r!su) zxb#D=Jdr;IYiYT^#k6&yA^q+M_3D8O z80TGgmBG_z!#@lfEXslv)nuznn#CND@PdW9i$`x-DLM&z5-@wSCEOW-a#H8v!|xmP{Fn@#byF}sFec3pm=i6conJAtklfVn?}(*&MU*;MDTc?w8Y&5<-R{7 z6JqK6l!UVLH;)?zfDx`Dn~Ndrt1jhbg~8H;iA`;s|6c*vAxmtQRuW3(VlFS19Sn=J}Lq z#G^wXxVCs$%&~mr$jUflRmRi76>(r>K(I7)qZF7z^9!6+Wcr@NSFoGo~ zkDKD2DYS6JEM`$e79al2hBZ96eomWic2GuBZhL`mk~1=5mleYBH?;&uMe~zf(^Co+ zWw6}o^*5hj1^P2>a>4nc$2ZVo8nkZyWaHk9;>uXnx0MadNtds}fkm{|zU|FEGAFt= zy$_=XL1-wkZDB5dB0)>JHfoYpRT!ta26`hW0fC&>$jmPx6A`sVPV{^8C9id+-Vp z!<>Z5(!b$#qM)SdLXfzT1>2CcG;DoYL<5DvIy5vdW~Mzn=9%9>Uw3wy$X{1J1nOZI zH}B9UIEX+VR~0Zt!bWi-j8>ieuU^E{cj5rY^Nkiw3gjy&1($aPp9YjFE8>jAklk7||989k`ol=8Th zWK$_Kw#-S&qWqIkBN}>?>hbH~%z$#}@{1O7>jZV&ld(%vMnfU>t_$Mu=J-mh9vKvG zM@R%ac}r4al)6F242tljlK#|dITkPu1Pi7BTiAfG8}EExBLqtGSyuAjTyLcOFdBcq z{>ls|CAXQ-rktzsIu)!(KNg!4iQRrmVmQ_1FeSlmJ-mTnzoI_j$s*J($PSp%V__{f z!mE%H3GeYVKP$}4rzD@QHji&1`j2{Ae;9k>W*1$^x&mS}aI5J4R_6)_XYYBP+zgDj z=IZTwy8>S#%h&5Zw{d@2q202*^wq+Oo6HQp&vMKl1D6>!5nFe_z*C{vhLw-CUarZw z%8?xzD658FU+{sRy*UL!sjOG1!$=R~x)v&0gncM` z^3`^)w0x`upM~_iIi=A9p-hOVuYXR?&^+oz_m{SFHW}(4h5D8q7td1lYd#W|;jLh3 zQsrbbMF<2`tVepnP#V^`V>6hAkh)1WjPGTkGNlF#Ip_voCkq#rgrL(GKddK{f8`*Z z*LmGh&%vKdKPC2&V`?wX4m?4`jj^O{7hwileI00&@5i`S@azf&l9+ij3RAp#i8aeI5cQZt11 zW#NxHBa2O6CPnfzDA(LgwySXwMaf%!SKUVQPws8b^Os9PdEiHM3yg{r4uWhAkWy7? zswR$f)SCanPU}-7-NNnM?ap`S#J{$Ek0;N=uQaOdqHJRrJWDV64o%oE&m(ge+_(sy z$27NrQ!gu2Z?(UCd9Ec7Y!2*?oR4Z!6iLeQ=m=Dg=5mo$9K|`pzvLLDl)bi4WM~=UG&~za_pn^MOP)V?v`Rnh>K72-&3G zcWs@0nDmB9R-~U0R&!QaL=bxJuy)~rMHfal0V;!Wp8{ZPtoN*+jmzK_wqCw|M8E+f zH;3{L3pl`I1iBI!yZC19MS$bGh)bwhf_Q?gpQa2!5JZ~7GQ4Y(>%iBJUn2hGqpO5H42ox7=)9}ib?^{g_|NJaUIxcz%Y^Mfbw z_NA9htaJglg0_z?I|&!OQrvW*ZdJ(vtQ)QZ4fCpRkN{QMEd>pTva#RgG;6 z&AJ@c@$;A_vV(^h5g}TEi9Zt{Oj(y#2T-a6>ss;=?tR)uLR%2u7~X;;)0XfZ0Pey@ z)?*(WG@O6(zqi(-yDUcg))U=&^re=ML3G%41N|%Dojhd1kkAH-wC*qfZcm~)`z1$> zCnO+@Uu9ScM_Z-fcW2kFO*$6$`LOvJ!DCAuoN4Gtbv0LR0HXqH961>F0clK_H*9}U zj={zmg{^h$nRSLaw&Ru@5hEJwAEIJDxj#nwB%Np%gXEql^$hM|;%K=N6Iju^JL*N@ z?=Lj2h=J`dJQfwA{~(A1#|G5{Ip?|sZKt;dVTe~8gOlGko6EE`1w(X^s!`)!wf~VS zvnEzX-wIN*meAeb)$8RtFCA$L4f&E)!DGtYkYPG5KVGV~M|9rP%2Rc$iN z64kE##79n;Xvn;$6d+vO*dI2B)M{dG0w{TmZ{J zYzdLZF%_ct+t6Ju=gj-l`fO;!u%t+Nz`n6_&%>hk&`4$=@vFGa-dgjhOE3BJ!%bhW zU}+pk%rj?0VR*Ujb_gdfBMED(4B@Oy;?NS-90MK6FLe7a$^yE+eU_caXK@I!A}C}xG+NoGQUScov?D7sB6pP$xtUxOq6lDT5XuDJXi<10AncRGU&$z1!lS|W?t#J#57Z&QS1qKYPe5j!IO-Cja|PWXC)0J$}vuFMhVcw%fjKOSf5At_BBSQ zc4nF?z~u+yn)ZB=L0wIspwqIwc{HJfyJ}Yea)ypX@4Qmc#35Mo% zEpb(d5Rx7raZ-qt4Vs)%PfL{{0jv!EA$+n*9_*d5LjP@i2@Rkaq42*->EK2J00{kp zCV3 z3h38(WMFnNOn%Sm<1py(;7AC}8B2+JnHWHHtVe`EzJ<^C}< z;_2omrQAkX;Gau*P84<}N)u{0(Roant`v`qmip3nMhvUV^PxTqbsz~c&i%fPL00q7 z?`lDiUkC(JAn;qPjUZl<;uFK7P+?~LS;3))S=F+j3HFsQ4;0;)>tC^4 zl$m``B~!h@_b<{burJ{SN`T=yd`#D_%<0);ic)Zp;@Q?p7!1W}HA2jc-aYD>Bt$OuwKR0^ni-e{a()yCi! z6<|&+a{=HlL6=W>w$Hk|p5IFlG!7@8L-A>4TfEbJbhq$F{2$%K1z^lug|e*7T@@rO z(aclCBE`a2Pw7aexbjEm@g}ok;lTK;+?Iu~seCdnny+ov ze>jgEQ7i|K%~@JoR!6Cy0Di2@9{|L5#a;*<#q5MLxJz;^$E6xNZ z;-Zs#@>D*-or92vvZ-9NNQ>KyEQ_Hi#e);E-MvAOliq{q9~*XAhZ@)QZZ{Fk3qa6U za<)T3y<-N2)#6gvuQBOyNJyrywp?+i-8A0 znu*-5gK?c)$`LBQ&|qsy`%kGA2K3*1@;T5T+M4SDq-y+cK~O%y3J;bSt-? z3wv5$_O=`QvRJ>{(aJjn#cOpd1icSUfD9 zfLw^|9*GZ?!-qo(Ln$`pmJPu|?PVgWzR9;r^509g;VaUY0e)?d*;%()(nq5@;RIVU zfB%}Aq)Vie7+|W)25Hkah-+wkSEe(ULu&FR6Gb_y9V_ z1@(5cDM8Thek>-k@MNDiRR92hmZ^r6nVcMe?gJwN;GjSNm=6T?u>ep60J#6c0Dv?U z;s0V~D4PGNfd&AeG5=5fN9}(;k{|B}`@i?k%J*y^n9^BH!`Z~p*_6lF(ez^jurRT3 zFfcJQurjH#F!Qjp@vv~vGqLb6F|k*nI{rTdTYD2rGmrm2LG+?w{DVOAzb80b+L}5! z8`?Vj-<*#)F?MDiHZFQ5rjJ9;??H7R9Nhopn7Uh<@o0(|i8w0SJ6Sqg+S^&$nFE+B T(fy4-N&r$~@}j?l4FdlIRx-Qi literal 0 HcmV?d00001 diff --git a/Rosetta/Core/Crypto/BiometricAuthManager.swift b/Rosetta/Core/Crypto/BiometricAuthManager.swift new file mode 100644 index 0000000..3c95733 --- /dev/null +++ b/Rosetta/Core/Crypto/BiometricAuthManager.swift @@ -0,0 +1,301 @@ +import Foundation +import LocalAuthentication +import Security + +// MARK: - BiometricError + +enum BiometricError: LocalizedError { + case notAvailable + case authenticationFailed + case saveFailed(OSStatus) + case loadFailed + case cancelled + + var errorDescription: String? { + switch self { + case .notAvailable: + return "Biometric authentication is not available on this device." + case .authenticationFailed: + return "Biometric authentication failed." + case .saveFailed(let status): + return "Failed to save biometric data (OSStatus \(status))." + case .loadFailed: + return "Failed to load biometric data." + case .cancelled: + return "Biometric authentication was cancelled." + } + } +} + +// MARK: - BiometricType + +enum BiometricType { + case faceID + case touchID + case none +} + +// MARK: - BiometricAuthManager + +/// Manages biometric authentication (Face ID / Touch ID) for account unlock. +/// +/// **Security model (iOS canonical, Secure Enclave):** +/// - Password is stored in Keychain with `SecAccessControl(.biometryCurrentSet)`. +/// - The Secure Enclave hardware protects the item — it is physically inaccessible +/// without successful biometric authentication, even on jailbroken devices. +/// - `.biometryCurrentSet` invalidates the item if biometric enrollment changes +/// (new face/fingerprint added or all removed). +/// - `.accessibleWhenPasscodeSetThisDeviceOnly` ensures the item is deleted +/// if the user removes their device passcode. +/// - Reading the item via `SecItemCopyMatching` with an `LAContext` automatically +/// triggers the system Face ID / Touch ID prompt — no separate `evaluatePolicy` needed. +/// +/// **Flow:** +/// 1. User enables biometric in Settings → enters password → password saved to +/// biometric-protected Keychain via `savePassword()` +/// 2. On next unlock → `loadPassword()` triggers Face ID via Secure Enclave → +/// system shows biometric prompt → on success, password is returned +/// 3. Password is passed to `SessionManager.startSession(password:)` +final class BiometricAuthManager: @unchecked Sendable { + + static let shared = BiometricAuthManager() + private init() {} + + private let keychainService = "com.rosetta.messenger.biometric" + private let enabledKeyPrefix = "biometric_enabled_" + + // MARK: - Availability + + /// Returns the type of biometric authentication available on the device. + var availableBiometricType: BiometricType { + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &error + ) else { + return .none + } + switch context.biometryType { + case .faceID: return .faceID + case .touchID: return .touchID + default: return .none + } + } + + /// Whether any biometric authentication is available on the device. + var isBiometricAvailable: Bool { + availableBiometricType != .none + } + + /// Localized name for the available biometric type ("Face ID" / "Touch ID"). + var biometricName: String { + switch availableBiometricType { + case .faceID: return "Face ID" + case .touchID: return "Touch ID" + case .none: return "Biometric" + } + } + + /// SF Symbol name for the available biometric type. + var biometricIconName: String { + switch availableBiometricType { + case .faceID: return "faceid" + case .touchID: return "touchid" + case .none: return "lock.open.fill" + } + } + + // MARK: - Enabled State (UserDefaults) + + /// Whether biometric unlock is enabled for the given account. + func isBiometricEnabled(forAccount publicKey: String) -> Bool { + UserDefaults.standard.bool(forKey: enabledKeyPrefix + publicKey) + } + + /// Sets the biometric enabled flag for the given account. + func setBiometricEnabled(_ enabled: Bool, forAccount publicKey: String) { + UserDefaults.standard.set(enabled, forKey: enabledKeyPrefix + publicKey) + } + + // MARK: - Password Storage (Keychain + Secure Enclave) + + /// Saves the account password to Keychain with biometric protection. + /// + /// The item is protected by `SecAccessControl` with `.biometryCurrentSet`, + /// meaning it can only be read after successful Face ID / Touch ID authentication, + /// and is invalidated if biometric enrollment changes. + func savePassword(_ password: String, forAccount publicKey: String) throws { + guard let data = password.data(using: .utf8) else { return } + + let key = passwordKey(for: publicKey) + + // Delete existing entry first + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: key, + ] + SecItemDelete(deleteQuery as CFDictionary) + + // Create biometric-protected access control (Secure Enclave) + var accessError: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + nil, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + .biometryCurrentSet, + &accessError + ) else { + throw BiometricError.saveFailed(-1) + } + + // Save with biometric protection — no LAContext needed for write, + // biometric is only required on read. + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessControl as String: accessControl, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw BiometricError.saveFailed(status) + } + } + + /// Loads the stored password from Keychain. Triggers Face ID / Touch ID automatically + /// via Secure Enclave — the system biometric prompt is shown as part of the Keychain read. + /// + /// Must be called via `async` — runs on background thread because `SecItemCopyMatching` + /// blocks while the biometric UI is displayed. + func loadPassword(forAccount publicKey: String) async throws -> String { + let key = passwordKey(for: publicKey) + let serviceName = keychainService + + return try await Task.detached(priority: .userInitiated) { + let context = LAContext() + context.localizedReason = "Unlock Rosetta" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + throw BiometricError.loadFailed + } + return password + case errSecUserCanceled: + throw BiometricError.cancelled + case errSecAuthFailed: + throw BiometricError.authenticationFailed + case errSecItemNotFound: + throw BiometricError.loadFailed + default: + throw BiometricError.loadFailed + } + }.value + } + + /// Deletes the stored password for the given account. + func deletePassword(forAccount publicKey: String) { + let key = passwordKey(for: publicKey) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } + + /// Whether a biometric-protected password exists for the given account. + /// Uses `kSecUseAuthenticationUIFail` to check existence WITHOUT triggering Face ID. + func hasStoredPassword(forAccount publicKey: String) -> Bool { + let key = passwordKey(for: publicKey) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: key, + kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + // errSecInteractionNotAllowed = item exists but requires biometric auth + return status == errSecInteractionNotAllowed || status == errSecSuccess + } + + // MARK: - Combined Unlock + + /// Loads stored password with biometric authentication (triggers Face ID / Touch ID). + /// The Secure Enclave handles the entire biometric flow — no separate `evaluatePolicy` needed. + /// Returns the password string for use with `SessionManager.startSession(password:)`. + func unlockWithBiometric(forAccount publicKey: String) async throws -> String { + try await loadPassword(forAccount: publicKey) + } + + /// Whether biometric unlock can be used right now for the given account. + func canUseBiometric(forAccount publicKey: String) -> Bool { + isBiometricAvailable + && isBiometricEnabled(forAccount: publicKey) + && hasStoredPassword(forAccount: publicKey) + } + + // MARK: - Standalone Authentication + + /// Shows the system biometric prompt (Face ID / Touch ID) for identity confirmation. + /// Use this when you need biometric verification without reading a Keychain item. + func authenticate(reason: String = "Unlock Rosetta") async throws { + let context = LAContext() + var error: NSError? + + guard context.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &error + ) else { + throw BiometricError.notAvailable + } + + do { + let success = try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: reason + ) + guard success else { throw BiometricError.authenticationFailed } + } catch let laError as LAError { + switch laError.code { + case .userCancel, .appCancel, .systemCancel: + throw BiometricError.cancelled + case .biometryNotAvailable, .biometryNotEnrolled: + throw BiometricError.notAvailable + default: + throw BiometricError.authenticationFailed + } + } + } + + // MARK: - Cleanup + + /// Removes all biometric data for the given account. + func clearAll(forAccount publicKey: String) { + deletePassword(forAccount: publicKey) + setBiometricEnabled(false, forAccount: publicKey) + } + + // MARK: - Private + + private func passwordKey(for publicKey: String) -> String { + "biometric_password_\(publicKey)" + } +} diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift index 7a4c69f..3140a31 100644 --- a/Rosetta/Core/Crypto/CryptoManager.swift +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -75,8 +75,10 @@ final class CryptoManager: @unchecked Sendable { return (privateKey, publicKey) } - // MARK: - Account Encryption (PBKDF2 + zlib + AES-256-CBC) + // MARK: - Account Encryption (PBKDF2-SHA256 + rawDeflate + AES-256-CBC) + /// iOS-only encryption for account keys, device identity, persistence snapshots. + /// Uses PBKDF2-HMAC-SHA256 + raw deflate (no zlib wrapper). nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String { let compressed = try CryptoPrimitives.rawDeflate(data) let key = CryptoPrimitives.pbkdf2( @@ -88,6 +90,21 @@ final class CryptoManager: @unchecked Sendable { return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" } + // MARK: - Desktop-Compatible Encryption (PBKDF2-SHA1 + zlibDeflate + AES-256-CBC) + + /// Desktop parity: CryptoJS PBKDF2 defaults to HMAC-SHA1, pako.deflate() is zlib-wrapped. + /// Use ONLY for cross-platform data: aesChachaKey, avatar blobs sent to server. + nonisolated func encryptWithPasswordDesktopCompat(_ data: Data, password: String) throws -> String { + let compressed = try CryptoPrimitives.zlibDeflate(data) + let key = CryptoPrimitives.pbkdf2( + password: password, salt: "rosetta", iterations: 1000, + keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1) + ) + let iv = try CryptoPrimitives.randomBytes(count: 16) + let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv) + return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" + } + nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data { let parts = encrypted.components(separatedBy: ":") guard parts.count == 2, @@ -96,19 +113,21 @@ final class CryptoManager: @unchecked Sendable { throw CryptoError.invalidData("Malformed encrypted string") } + // SHA256 first: all existing iOS data was encrypted with SHA256. + // SHA1 fallback: desktop CryptoJS default + encryptWithPasswordDesktopCompat. + // ⚠️ SHA256 MUST be first — wrong-key AES-CBC can randomly produce valid + // PKCS7 padding (~1/256 chance) and garbage may survive zlib inflate, + // causing false-positive decryption with corrupt data. let prfOrder: [CCPseudoRandomAlgorithm] = [ - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // Desktop/Android current - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Legacy iOS + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // iOS encryptWithPassword + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), // Desktop / encryptWithPasswordDesktopCompat ] - // 1) Preferred path: AES-CBC + zlib inflate + // 1) Preferred path: AES-CBC + inflate (handles both rawDeflate and zlibDeflate) for prf in prfOrder { if let result = try? decryptWithPassword( - ciphertext: ciphertext, - iv: iv, - password: password, - prf: prf, - expectsCompressed: true + ciphertext: ciphertext, iv: iv, password: password, + prf: prf, expectsCompressed: true ) { return result } @@ -117,11 +136,8 @@ final class CryptoManager: @unchecked Sendable { // 2) Fallback: AES-CBC without compression (very old/legacy payloads) for prf in prfOrder { if let result = try? decryptWithPassword( - ciphertext: ciphertext, - iv: iv, - password: password, - prf: prf, - expectsCompressed: false + ciphertext: ciphertext, iv: iv, password: password, + prf: prf, expectsCompressed: false ) { return result } diff --git a/Rosetta/Core/Crypto/CryptoPrimitives.swift b/Rosetta/Core/Crypto/CryptoPrimitives.swift index f31dc7e..d11e299 100644 --- a/Rosetta/Core/Crypto/CryptoPrimitives.swift +++ b/Rosetta/Core/Crypto/CryptoPrimitives.swift @@ -112,10 +112,41 @@ enum CryptoPrimitives { } } -// MARK: - zlib Raw Deflate / Inflate +// MARK: - zlib Deflate / Inflate extension CryptoPrimitives { - /// Raw deflate compression (no zlib header, compatible with pako.deflate / Java Deflater(nowrap=true)). + + /// Zlib-wrapped deflate compression (0x78 header + raw deflate + adler32 trailer). + /// Compatible with desktop `pako.deflate()` and Node.js `zlib.deflateSync()`. + /// Desktop CryptoJS uses `pako.deflate()` which produces zlib-wrapped output; + /// `pako.inflate()` on the desktop expects this format — raw deflate will fail. + static func zlibDeflate(_ data: Data) throws -> Data { + let raw = try rawDeflate(data) + var result = Data(capacity: 2 + raw.count + 4) + // zlib header: CMF=0x78 (deflate method, 32K window), FLG=0x9C (default level, check bits) + result.append(contentsOf: [0x78, 0x9C] as [UInt8]) + result.append(raw) + // Adler-32 checksum of the original uncompressed data (big-endian) + let checksum = adler32(data) + result.append(UInt8((checksum >> 24) & 0xFF)) + result.append(UInt8((checksum >> 16) & 0xFF)) + result.append(UInt8((checksum >> 8) & 0xFF)) + result.append(UInt8(checksum & 0xFF)) + return result + } + + /// Adler-32 checksum (used for zlib trailer). + private static func adler32(_ data: Data) -> UInt32 { + var a: UInt32 = 1 + var b: UInt32 = 0 + for byte in data { + a = (a &+ UInt32(byte)) % 65521 + b = (b &+ a) % 65521 + } + return (b << 16) | a + } + + /// Raw deflate compression (no zlib header, compatible with Java Deflater(nowrap=true)). static func rawDeflate(_ data: Data) throws -> Data { let sourceSize = data.count let destinationSize = sourceSize + 512 @@ -132,14 +163,36 @@ extension CryptoPrimitives { return destination.prefix(compressedSize) } - /// Raw inflate decompression (no zlib header, compatible with pako.inflate / Java Inflater(nowrap=true)). + /// Inflate decompression supporting both raw deflate and zlib-wrapped formats. + /// Apple's `COMPRESSION_ZLIB` uses windowBits=-15 (raw deflate only). + /// Desktop `pako.deflate()` / iOS `zlibDeflate()` produce zlib-wrapped data + /// (0x78 header + raw deflate + adler32 trailer). + /// + /// ⚠️ zlib-wrapped data MUST be stripped FIRST — `tryRawInflate` can produce + /// garbage output from zlib header bytes (0x78 0x9C are valid but meaningless + /// raw deflate instructions), causing false-positive decompression. static func rawInflate(_ data: Data) throws -> Data { + // 1. Strip zlib wrapper FIRST if present (iOS zlibDeflate / desktop pako.deflate). + // Must be tried before raw inflate to avoid false-positive decompression + // where raw inflate interprets the zlib header as deflate instructions. + if data.count > 6, data[data.startIndex] == 0x78 { + let stripped = Data(data[(data.startIndex + 2) ..< (data.endIndex - 4)]) + if let result = tryRawInflate(stripped) { + return result + } + } + // 2. Try raw deflate (legacy iOS rawDeflate / Android-produced) + if let result = tryRawInflate(data) { + return result + } + throw CryptoError.compressionFailed + } + + private static func tryRawInflate(_ data: Data) -> Data? { let sourceSize = data.count - for multiplier in [4, 8, 16, 32] { - var destinationSize = max(sourceSize * multiplier, 256) + let destinationSize = max(sourceSize * multiplier, 256) var destination = Data(count: destinationSize) - let decompressedSize = destination.withUnsafeMutableBytes { destPtr in data.withUnsafeBytes { srcPtr in guard let dBase = destPtr.bindMemory(to: UInt8.self).baseAddress, @@ -147,12 +200,11 @@ extension CryptoPrimitives { return compression_decode_buffer(dBase, destinationSize, sBase, sourceSize, nil, COMPRESSION_ZLIB) } } - - if decompressedSize > 0 && decompressedSize < destinationSize { + if decompressedSize > 0, decompressedSize < destinationSize { return destination.prefix(decompressedSize) } } - throw CryptoError.compressionFailed + return nil } } diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index bbf5e71..317c643 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -90,6 +90,18 @@ enum MessageCrypto { return try decryptWithKeyAndNonce(ciphertext: ciphertext, keyAndNonce: plainKeyAndNonce) } + /// Extract raw decrypted key+nonce data from an encrypted key string (ECDH path). + /// Used for decrypting MESSAGES-type attachment blobs (desktop parity). + static func extractDecryptedKeyData( + encryptedKey: String, + myPrivateKeyHex: String + ) -> Data? { + guard let candidates = try? decryptKeyFromSenderCandidates( + encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex + ) else { return nil } + return candidates.first + } + /// Android parity helper: /// `String(bytes, UTF_8)` (replacement for invalid sequences) + `toByteArray(ISO_8859_1)`. static func androidUtf8BytesToLatin1Bytes(_ utf8Bytes: Data) -> Data { diff --git a/Rosetta/Core/Data/Models/Account.swift b/Rosetta/Core/Data/Models/Account.swift index a72b693..8239d61 100644 --- a/Rosetta/Core/Data/Models/Account.swift +++ b/Rosetta/Core/Data/Models/Account.swift @@ -35,6 +35,11 @@ extension Account { extension Account { enum KeychainKey { + /// Legacy single-account Keychain key (kept for backward compatibility / migration). static let account = "currentAccount" + /// Multi-account: array of all accounts stored in Keychain. + static let allAccounts = "allAccounts" + /// UserDefaults key tracking which account is currently active. + static let activeAccountKey = "activeAccountPublicKey" } } diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift index ec3f4d0..bf446d2 100644 --- a/Rosetta/Core/Data/Models/Dialog.swift +++ b/Rosetta/Core/Data/Models/Dialog.swift @@ -3,9 +3,9 @@ import Foundation // MARK: - System Accounts enum SystemAccounts { - static let safePublicKey = "0x000000000000000000000000000000000000000002" + static let safePublicKey = Account.safePublicKey static let safeTitle = "Safe" - static let updatesPublicKey = "0x000000000000000000000000000000000000000001" + static let updatesPublicKey = Account.updatesPublicKey static let updatesTitle = "Rosetta Updates" static let systemKeys: Set = [safePublicKey, updatesPublicKey] diff --git a/Rosetta/Core/Data/Models/RecentSearch.swift b/Rosetta/Core/Data/Models/RecentSearch.swift index f31081c..a8d9ae8 100644 --- a/Rosetta/Core/Data/Models/RecentSearch.swift +++ b/Rosetta/Core/Data/Models/RecentSearch.swift @@ -4,5 +4,26 @@ struct RecentSearch: Codable, Equatable, Sendable { let publicKey: String var title: String var username: String - var lastSeenText: String + /// Verification level from server (0 = not verified). + var verified: Int = 0 + + // MARK: - Migration: ignore unknown keys from old persisted data + enum CodingKeys: String, CodingKey { + case publicKey, title, username, verified + } + + init(publicKey: String, title: String, username: String, verified: Int = 0) { + self.publicKey = publicKey + self.title = title + self.username = username + self.verified = verified + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + publicKey = try container.decode(String.self, forKey: .publicKey) + title = try container.decode(String.self, forKey: .title) + username = try container.decode(String.self, forKey: .username) + verified = (try? container.decode(Int.self, forKey: .verified)) ?? 0 + } } diff --git a/Rosetta/Core/Data/Repositories/AvatarRepository.swift b/Rosetta/Core/Data/Repositories/AvatarRepository.swift new file mode 100644 index 0000000..7222369 --- /dev/null +++ b/Rosetta/Core/Data/Repositories/AvatarRepository.swift @@ -0,0 +1,136 @@ +import Foundation +import Observation +import UIKit + +/// Manages avatar image storage on disk with in-memory cache. +/// +/// Desktop parity: `AvatarProvider.tsx` stores avatars encrypted with +/// `AVATAR_PASSWORD_TO_ENCODE = "rosetta-a"`. iOS relies on iOS Data Protection +/// (sandbox encryption) instead — adding AES would add complexity without security benefit. +/// +/// Storage: `Application Support/Rosetta/Avatars/{normalizedKey}.jpg` +@Observable +@MainActor +final class AvatarRepository { + + static let shared = AvatarRepository() + private init() {} + + /// Incremented on every avatar save/remove — views that read this property + /// will re-render and pick up the latest avatar from cache. + private(set) var avatarVersion: UInt = 0 + + /// In-memory cache for decoded UIImages — keyed by normalized public key. + private let cache = NSCache() + + /// JPEG compression quality (0.8 = reasonable size for avatars). + private let compressionQuality: CGFloat = 0.8 + + // MARK: - Public API + + /// Saves an avatar image for the given public key. + /// Compresses to JPEG, writes to disk, updates cache. + func saveAvatar(publicKey: String, image: UIImage) { + let key = normalizedKey(publicKey) + guard let data = image.jpegData(compressionQuality: compressionQuality) else { return } + let url = avatarURL(for: key) + ensureDirectoryExists() + try? data.write(to: url, options: .atomic) + cache.setObject(image, forKey: key as NSString) + avatarVersion += 1 + } + + /// Saves an avatar from a base64-encoded image string (used when receiving from network). + /// Desktop parity: re-encodes with `AVATAR_PASSWORD_TO_ENCODE` — iOS stores as plain JPEG. + func saveAvatarFromBase64(_ base64: String, publicKey: String) { + guard let data = Data(base64Encoded: base64), + let image = UIImage(data: data) else { return } + saveAvatar(publicKey: publicKey, image: image) + } + + /// Loads avatar for the given public key. + /// System accounts return a bundled static avatar (desktop parity). + /// Regular accounts check cache first, then disk. + func loadAvatar(publicKey: String) -> UIImage? { + // Desktop parity: system accounts have hardcoded avatars + if let systemAvatar = systemAccountAvatar(for: publicKey) { + return systemAvatar + } + let key = normalizedKey(publicKey) + if let cached = cache.object(forKey: key as NSString) { + return cached + } + let url = avatarURL(for: key) + guard let data = try? Data(contentsOf: url), + let image = UIImage(data: data) else { + return nil + } + cache.setObject(image, forKey: key as NSString) + return image + } + + /// Returns bundled avatar for system accounts, nil for regular accounts. + private func systemAccountAvatar(for publicKey: String) -> UIImage? { + if publicKey == SystemAccounts.safePublicKey { + return UIImage(named: "safe-avatar") + } + return nil + } + + /// Returns base64-encoded JPEG string for sending over the network. + /// Desktop parity: `imagePrepareForNetworkTransfer()` returns base64. + func loadAvatarBase64(publicKey: String) -> String? { + guard let image = loadAvatar(publicKey: publicKey), + let data = image.jpegData(compressionQuality: compressionQuality) else { + return nil + } + return data.base64EncodedString() + } + + /// Removes the avatar for the given public key from disk and cache. + func removeAvatar(publicKey: String) { + let key = normalizedKey(publicKey) + cache.removeObject(forKey: key as NSString) + let url = avatarURL(for: key) + try? FileManager.default.removeItem(at: url) + avatarVersion += 1 + } + + /// Clears entire avatar cache (used on full data reset). + func clearAll() { + cache.removeAllObjects() + if let directory = avatarsDirectory { + try? FileManager.default.removeItem(at: directory) + } + avatarVersion += 1 + } + + // MARK: - Private + + private var avatarsDirectory: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("Rosetta/Avatars", isDirectory: true) + } + + private func avatarURL(for normalizedKey: String) -> URL { + avatarsDirectory! + .appendingPathComponent("\(normalizedKey).jpg") + } + + private func normalizedKey(_ publicKey: String) -> String { + publicKey + .replacingOccurrences(of: "0x", with: "") + .lowercased() + } + + private func ensureDirectoryExists() { + guard let directory = avatarsDirectory else { return } + if !FileManager.default.fileExists(atPath: directory.path) { + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } + } +} diff --git a/Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift b/Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift index 2f9979c..0e227a5 100644 --- a/Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift +++ b/Rosetta/Core/Data/Repositories/ChatPersistenceStore.swift @@ -25,10 +25,11 @@ actor ChatPersistenceStore { let fileURL = rootDirectory.appendingPathComponent(fileName, isDirectory: false) guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } guard let data = try? Data(contentsOf: fileURL) else { return nil } - if let password, - let encryptedSnapshot = String(data: data, encoding: .utf8), - let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedSnapshot, password: password), - let decoded = try? decoder.decode(type, from: decrypted) { + if let password { + guard let encryptedSnapshot = String(data: data, encoding: .utf8), + let decrypted = try? CryptoManager.shared.decryptWithPassword(encryptedSnapshot, password: password), + let decoded = try? decoder.decode(type, from: decrypted) + else { return nil } return decoded } return try? decoder.decode(type, from: data) diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 5105ee8..b180698 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -14,16 +14,37 @@ final class DialogRepository { private var currentAccount: String = "" private var storagePassword: String = "" private var persistTask: Task? - private var _sortedDialogsCache: [Dialog]? + + // MARK: - Sort Caches + + /// Cached sort order (opponent keys). Invalidated only when sort-affecting + /// fields change (isPinned, lastMessageTimestamp, dialog added/removed). + /// Non-sort mutations (delivery status, online, unread) preserve this cache, + /// downgrading the sort cost from O(n log n) to O(n) compactMap. + @ObservationIgnored private var _sortedKeysCache: [String]? + + /// Cached sorted dialog array. Invalidated on every `dialogs` mutation via didSet. + /// Multiple reads within the same SwiftUI body evaluation return this reference. + @ObservationIgnored private var _sortedDialogsCache: [Dialog]? var sortedDialogs: [Dialog] { if let cached = _sortedDialogsCache { return cached } - let sorted = Array(dialogs.values).sorted { - if $0.isPinned != $1.isPinned { return $0.isPinned } - return $0.lastMessageTimestamp > $1.lastMessageTimestamp + + let result: [Dialog] + if let keys = _sortedKeysCache { + // Sort order still valid — rebuild values from fresh dialogs (O(n) lookups). + result = keys.compactMap { dialogs[$0] } + } else { + // Full re-sort needed (O(n log n)) — only when sort-affecting fields changed. + result = Array(dialogs.values).sorted { + if $0.isPinned != $1.isPinned { return $0.isPinned } + return $0.lastMessageTimestamp > $1.lastMessageTimestamp + } + _sortedKeysCache = result.map(\.opponentKey) } - _sortedDialogsCache = sorted - return sorted + + _sortedDialogsCache = result + return result } private init() {} @@ -57,12 +78,14 @@ final class DialogRepository { .filter { $0.account == account } .map { ($0.opponentKey, $0) } ) + _sortedKeysCache = nil } func reset(clearPersisted: Bool = false) { persistTask?.cancel() persistTask = nil dialogs.removeAll() + _sortedKeysCache = nil storagePassword = "" guard !currentAccount.isEmpty else { return } @@ -83,6 +106,7 @@ final class DialogRepository { currentAccount = dialog.account } dialogs[dialog.opponentKey] = dialog + _sortedKeysCache = nil schedulePersist() } @@ -137,6 +161,7 @@ final class DialogRepository { } dialogs[opponentKey] = dialog + _sortedKeysCache = nil schedulePersist() // Desktop parity: re-evaluate request status based on last N messages. @@ -151,15 +176,20 @@ final class DialogRepository { myPublicKey: String ) { if var existing = dialogs[opponentKey] { - if !title.isEmpty { + var changed = false + if !title.isEmpty, existing.opponentTitle != title { existing.opponentTitle = title + changed = true } - if !username.isEmpty { + if !username.isEmpty, existing.opponentUsername != username { existing.opponentUsername = username + changed = true } if verified > existing.verified { existing.verified = verified + changed = true } + guard changed else { return } dialogs[opponentKey] = existing schedulePersist() return @@ -183,11 +213,13 @@ final class DialogRepository { lastMessageFromMe: false, lastMessageDelivered: .waiting ) + _sortedKeysCache = nil schedulePersist() } func updateOnlineState(publicKey: String, isOnline: Bool) { guard var dialog = dialogs[publicKey] else { return } + guard dialog.isOnline != isOnline else { return } dialog.isOnline = isOnline if !isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) @@ -219,15 +251,30 @@ final class DialogRepository { func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) { guard var dialog = dialogs[publicKey] else { return } - if !title.isEmpty { dialog.opponentTitle = title } - if !username.isEmpty { dialog.opponentUsername = username } - if verified > 0 { dialog.verified = max(dialog.verified, verified) } + var changed = false + if !title.isEmpty, dialog.opponentTitle != title { + dialog.opponentTitle = title + changed = true + } + if !username.isEmpty, dialog.opponentUsername != username { + dialog.opponentUsername = username + changed = true + } + if verified > 0, dialog.verified < verified { + dialog.verified = verified + changed = true + } // Server protocol: 0 = ONLINE, 1 = OFFLINE (matches desktop OnlineState enum) // -1 = not provided (don't update) if online >= 0 { - dialog.isOnline = online == 0 - if !dialog.isOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) } + let newOnline = online == 0 + if dialog.isOnline != newOnline { + dialog.isOnline = newOnline + if !newOnline { dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) } + changed = true + } } + guard changed else { return } dialogs[publicKey] = dialog schedulePersist() } @@ -242,15 +289,15 @@ final class DialogRepository { func markOutgoingAsRead(opponentKey: String) { guard var dialog = dialogs[opponentKey] else { return } - if dialog.lastMessageFromMe { - dialog.lastMessageDelivered = .read - } + guard dialog.lastMessageFromMe, dialog.lastMessageDelivered != .read else { return } + dialog.lastMessageDelivered = .read dialogs[opponentKey] = dialog schedulePersist() } func deleteDialog(opponentKey: String) { dialogs.removeValue(forKey: opponentKey) + _sortedKeysCache = nil schedulePersist() } @@ -262,6 +309,7 @@ final class DialogRepository { guard let lastMsg = messages.last else { dialogs.removeValue(forKey: opponentKey) + _sortedKeysCache = nil schedulePersist() return } @@ -271,6 +319,7 @@ final class DialogRepository { dialog.lastMessageFromMe = lastMsg.fromPublicKey == currentAccount dialog.lastMessageDelivered = lastMsg.deliveryStatus dialogs[opponentKey] = dialog + _sortedKeysCache = nil schedulePersist() } @@ -305,6 +354,7 @@ final class DialogRepository { let messages = MessageRepository.shared.messages(for: opponentKey) if messages.isEmpty { dialogs.removeValue(forKey: opponentKey) + _sortedKeysCache = nil schedulePersist() } } @@ -322,6 +372,7 @@ final class DialogRepository { dialog.isPinned.toggle() dialogs[opponentKey] = dialog + _sortedKeysCache = nil schedulePersist() } @@ -390,7 +441,7 @@ final class DialogRepository { let storagePassword = self.storagePassword persistTask?.cancel() persistTask = Task(priority: .utility) { - try? await Task.sleep(for: .milliseconds(180)) + try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } await ChatPersistenceStore.shared.save( snapshot, diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 243ddf2..babeeb9 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -5,14 +5,15 @@ import Combine @MainActor final class MessageRepository: ObservableObject { static let shared = MessageRepository() - // Desktop parity: MESSAGE_MAX_LOADED = 40 per dialog. - private let maxMessagesPerDialog = ProtocolConstants.messageMaxCached @Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:] @Published private(set) var typingDialogs: Set = [] private var activeDialogs: Set = [] private var messageToDialog: [String: String] = [:] + /// Persistent set of all message IDs ever seen — survives cap eviction. + /// Prevents duplicate messages during repeated sync cycles. + private var allKnownMessageIds: Set = [] private var typingResetTasks: [String: Task] = [:] private var persistTask: Task? private var currentAccount: String = "" @@ -44,6 +45,7 @@ final class MessageRepository: ObservableObject { } typingResetTasks.removeAll() messageToDialog.removeAll() + allKnownMessageIds.removeAll() let fileName = Self.messagesFileName(for: account) let stored = await ChatPersistenceStore.shared.load( @@ -59,14 +61,20 @@ final class MessageRepository: ObservableObject { } return $0.id < $1.id } - if sorted.count > maxMessagesPerDialog { - sorted = Array(sorted.suffix(maxMessagesPerDialog)) - } restored[dialogKey] = sorted for message in sorted { messageToDialog[message.id] = dialogKey + allKnownMessageIds.insert(message.id) } } + // Restore persisted known IDs (includes evicted message IDs) + if let savedIds = await ChatPersistenceStore.shared.load( + Set.self, + fileName: Self.knownIdsFileName(for: account), + password: storagePassword + ) { + allKnownMessageIds.formUnion(savedIds) + } messagesByDialog = restored } @@ -81,7 +89,7 @@ final class MessageRepository: ObservableObject { } func hasMessage(_ messageId: String) -> Bool { - messageToDialog[messageId] != nil + allKnownMessageIds.contains(messageId) } func isLatestMessage(_ messageId: String, in dialogKey: String) -> Bool { @@ -123,6 +131,7 @@ final class MessageRepository: ObservableObject { let outgoingStatus: DeliveryStatus = (fromMe && fromSync) ? .delivered : (fromMe ? .waiting : .delivered) messageToDialog[messageId] = dialogKey + allKnownMessageIds.insert(messageId) updateMessages(for: dialogKey) { messages in if let existingIndex = messages.firstIndex(where: { $0.id == messageId }) { @@ -291,6 +300,7 @@ final class MessageRepository: ObservableObject { typingDialogs.removeAll() activeDialogs.removeAll() messageToDialog.removeAll() + allKnownMessageIds.removeAll() storagePassword = "" guard !currentAccount.isEmpty else { return } @@ -298,9 +308,11 @@ final class MessageRepository: ObservableObject { currentAccount = "" guard clearPersisted else { return } - let fileName = Self.messagesFileName(for: accountToReset) + let messagesFile = Self.messagesFileName(for: accountToReset) + let knownIdsFile = Self.knownIdsFileName(for: accountToReset) Task(priority: .utility) { - await ChatPersistenceStore.shared.remove(fileName: fileName) + await ChatPersistenceStore.shared.remove(fileName: messagesFile) + await ChatPersistenceStore.shared.remove(fileName: knownIdsFile) } } @@ -319,14 +331,6 @@ final class MessageRepository: ObservableObject { return $0.id < $1.id } } - if messages.count > maxMessagesPerDialog { - let overflow = messages.count - maxMessagesPerDialog - let dropped = messages.prefix(overflow) - for message in dropped { - messageToDialog.removeValue(forKey: message.id) - } - messages.removeFirst(overflow) - } messagesByDialog[dialogKey] = messages schedulePersist() } @@ -340,21 +344,25 @@ final class MessageRepository: ObservableObject { guard !currentAccount.isEmpty else { return } let snapshot = messagesByDialog - let fileName = Self.messagesFileName(for: currentAccount) + let idsSnapshot = allKnownMessageIds + let messagesFile = Self.messagesFileName(for: currentAccount) + let knownIdsFile = Self.knownIdsFileName(for: currentAccount) let storagePassword = self.storagePassword + let password = storagePassword.isEmpty ? nil : storagePassword persistTask?.cancel() persistTask = Task(priority: .utility) { - try? await Task.sleep(for: .milliseconds(220)) + try? await Task.sleep(for: .milliseconds(400)) guard !Task.isCancelled else { return } - await ChatPersistenceStore.shared.save( - snapshot, - fileName: fileName, - password: storagePassword.isEmpty ? nil : storagePassword - ) + await ChatPersistenceStore.shared.save(snapshot, fileName: messagesFile, password: password) + await ChatPersistenceStore.shared.save(idsSnapshot, fileName: knownIdsFile, password: password) } } private static func messagesFileName(for accountPublicKey: String) -> String { ChatPersistenceStore.accountScopedFileName(prefix: "messages", accountPublicKey: accountPublicKey) } + + private static func knownIdsFileName(for accountPublicKey: String) -> String { + ChatPersistenceStore.accountScopedFileName(prefix: "known_ids", accountPublicKey: accountPublicKey) + } } diff --git a/Rosetta/Core/Data/Repositories/RecentSearchesRepository.swift b/Rosetta/Core/Data/Repositories/RecentSearchesRepository.swift index b006057..cdcba9d 100644 --- a/Rosetta/Core/Data/Repositories/RecentSearchesRepository.swift +++ b/Rosetta/Core/Data/Repositories/RecentSearchesRepository.swift @@ -30,7 +30,7 @@ final class RecentSearchesRepository: ObservableObject { publicKey: user.publicKey, title: user.title, username: user.username, - lastSeenText: user.online == 0 ? "online" : "last seen recently" + verified: Int(user.verified) ) add(recent) } diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index 875b53d..59bee97 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -22,7 +22,9 @@ enum PacketRegistry { 0x06: { PacketMessage() }, 0x07: { PacketRead() }, 0x08: { PacketDelivery() }, + 0x09: { PacketDeviceNew() }, 0x0B: { PacketTyping() }, + 0x0F: { PacketRequestTransport() }, 0x10: { PacketPushNotification() }, 0x17: { PacketDeviceList() }, 0x18: { PacketDeviceResolve() }, @@ -58,6 +60,7 @@ enum AttachmentType: Int, Codable { case image = 0 case messages = 1 case file = 2 + case avatar = 3 } struct MessageAttachment: Codable { diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketDeviceNew.swift b/Rosetta/Core/Network/Protocol/Packets/PacketDeviceNew.swift new file mode 100644 index 0000000..b5f5313 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketDeviceNew.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Device new login packet (0x09) — server notifies all devices when a new device logs in. +/// Field order matches TypeScript: ipAddress, deviceId, deviceName, deviceOs. +struct PacketDeviceNew: Packet { + static let packetId = 0x09 + + var ipAddress: String = "" + var deviceId: String = "" + var deviceName: String = "" + var deviceOs: String = "" + + func write(to stream: Stream) { + stream.writeString(ipAddress) + stream.writeString(deviceId) + stream.writeString(deviceName) + stream.writeString(deviceOs) + } + + mutating func read(from stream: Stream) { + ipAddress = stream.readString() + deviceId = stream.readString() + deviceName = stream.readString() + deviceOs = stream.readString() + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketRequestTransport.swift b/Rosetta/Core/Network/Protocol/Packets/PacketRequestTransport.swift new file mode 100644 index 0000000..c2637bc --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketRequestTransport.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Transport server request/response packet (0x0F). +/// Desktop parity: `packet.requesttransport.ts`. +/// +/// Client sends empty packet to request transport server URL. +/// Server responds with the URL for file upload/download. +struct PacketRequestTransport: Packet { + static let packetId = 0x0F + + var transportServer: String = "" + + func write(to stream: Stream) { + stream.writeString(transportServer) + } + + mutating func read(from stream: Stream) { + transportServer = stream.readString() + } +} diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index c64d9d5..3073483 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -46,6 +46,7 @@ final class ProtocolManager: @unchecked Sendable { var onSearchResult: ((PacketSearch) -> Void)? var onTypingReceived: ((PacketTyping) -> Void)? var onSyncReceived: ((PacketSync) -> Void)? + var onDeviceNewReceived: ((PacketDeviceNew) -> Void)? var onHandshakeCompleted: ((PacketHandshake) -> Void)? // MARK: - Private @@ -98,6 +99,9 @@ final class ProtocolManager: @unchecked Sendable { connectionState = .disconnected savedPublicKey = nil savedPrivateHash = nil + Task { @MainActor in + TransportManager.shared.reset() + } } /// Immediately reconnect after returning from background, bypassing backoff. @@ -292,10 +296,21 @@ final class ProtocolManager: @unchecked Sendable { if let p = packet as? PacketDelivery { onDeliveryReceived?(p) } + case 0x09: + if let p = packet as? PacketDeviceNew { + onDeviceNewReceived?(p) + } case 0x0B: if let p = packet as? PacketTyping { onTypingReceived?(p) } + case 0x0F: + if let p = packet as? PacketRequestTransport { + Self.logger.info("📥 Transport server: \(p.transportServer)") + Task { @MainActor in + TransportManager.shared.setTransportServer(p.transportServer) + } + } case 0x17: if let p = packet as? PacketDeviceList { handleDeviceList(p) @@ -350,6 +365,10 @@ final class ProtocolManager: @unchecked Sendable { flushPacketQueue() startHeartbeat(interval: packet.heartbeatInterval) + + // Desktop parity: request transport server URL after handshake. + sendPacketDirect(PacketRequestTransport()) + onHandshakeCompleted?(packet) case .needDeviceVerification: diff --git a/Rosetta/Core/Network/TransportManager.swift b/Rosetta/Core/Network/TransportManager.swift new file mode 100644 index 0000000..9cb83b4 --- /dev/null +++ b/Rosetta/Core/Network/TransportManager.swift @@ -0,0 +1,159 @@ +import Foundation +import os +import Observation + +// MARK: - TransportError + +enum TransportError: LocalizedError { + case noTransportServer + case uploadFailed(statusCode: Int) + case downloadFailed(statusCode: Int) + case invalidResponse + case missingTag + + var errorDescription: String? { + switch self { + case .noTransportServer: return "Transport server URL not set" + case .uploadFailed(let code): return "Upload failed (HTTP \(code))" + case .downloadFailed(let code): return "Download failed (HTTP \(code))" + case .invalidResponse: return "Invalid response from transport server" + case .missingTag: return "Upload response missing file tag" + } + } +} + +// MARK: - TransportManager + +/// Manages file upload/download to the transport server. +/// Desktop parity: `TransportProvider.tsx`. +/// +/// Flow: +/// 1. After handshake, client sends `PacketRequestTransport` (0x0F) +/// 2. Server responds with transport server URL +/// 3. Upload: `POST {url}/u` with multipart form data → returns `{"t": "tag"}` +/// 4. Download: `GET {url}/d/{tag}` → returns file content +@Observable +final class TransportManager: @unchecked Sendable { + + static let shared = TransportManager() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Transport") + + /// Transport server URL received from server via PacketRequestTransport (0x0F). + private(set) var transportServer: String? + + private let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + session = URLSession(configuration: config) + } + + // MARK: - Configuration + + /// Called when PacketRequestTransport (0x0F) response arrives. + @MainActor + func setTransportServer(_ url: String) { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + transportServer = trimmed + Self.logger.info("Transport server set: \(trimmed)") + } + + /// Resets transport server (on disconnect). + @MainActor + func reset() { + transportServer = nil + } + + // MARK: - Upload + + /// Uploads file content to the transport server. + /// Desktop parity: `TransportProvider.tsx` `uploadFile()`. + /// + /// - Parameters: + /// - id: Unique file identifier (used as filename in multipart). + /// - content: Raw file content to upload. + /// - Returns: Server-assigned tag for later download. + func uploadFile(id: String, content: Data) async throws -> String { + guard let serverUrl = await MainActor.run(body: { transportServer }) else { + throw TransportError.noTransportServer + } + + guard let url = URL(string: "\(serverUrl)/u") else { + throw TransportError.noTransportServer + } + + Self.logger.info("Uploading file \(id) (\(content.count) bytes) to \(serverUrl)/u") + + let boundary = "Boundary-\(UUID().uuidString)" + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // Build multipart body (matches desktop: FormData.append('file', Blob, id)) + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(id)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) + body.append(content) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TransportError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + Self.logger.error("Upload failed: HTTP \(httpResponse.statusCode)") + throw TransportError.uploadFailed(statusCode: httpResponse.statusCode) + } + + // Parse JSON response: {"t": "tag"} + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tag = json["t"] as? String else { + throw TransportError.missingTag + } + + Self.logger.info("Upload complete: id=\(id), tag=\(tag)") + return tag + } + + // MARK: - Download + + /// Downloads file content from the transport server. + /// Desktop parity: `TransportProvider.tsx` `downloadFile()`. + /// + /// - Parameter tag: Server-assigned file tag from upload response. + /// - Returns: Raw file content. + func downloadFile(tag: String) async throws -> Data { + guard let serverUrl = await MainActor.run(body: { transportServer }) else { + throw TransportError.noTransportServer + } + + guard let url = URL(string: "\(serverUrl)/d/\(tag)") else { + throw TransportError.noTransportServer + } + + Self.logger.info("Downloading file tag=\(tag) from \(serverUrl)/d/\(tag)") + + let request = URLRequest(url: url) + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw TransportError.invalidResponse + } + + guard httpResponse.statusCode == 200 else { + Self.logger.error("Download failed: HTTP \(httpResponse.statusCode)") + throw TransportError.downloadFailed(statusCode: httpResponse.statusCode) + } + + Self.logger.info("Download complete: tag=\(tag), \(data.count) bytes") + return data + } +} diff --git a/Rosetta/Core/Services/AccountManager.swift b/Rosetta/Core/Services/AccountManager.swift index efe7f59..cbdae1e 100644 --- a/Rosetta/Core/Services/AccountManager.swift +++ b/Rosetta/Core/Services/AccountManager.swift @@ -4,27 +4,45 @@ import os // MARK: - AccountManager -/// Manages the current user account: creation, import, persistence, and retrieval. -/// Persists encrypted account data to the iOS Keychain. +/// Manages user accounts: creation, import, persistence, switching, and retrieval. +/// Desktop parity: `AccountProvider.tsx` with `allAccounts`, `loginedAccount`, `loginDiceAccount`. +/// +/// Supports multiple accounts stored as `[Account]` in Keychain. +/// Active account tracked via UserDefaults key. +/// Migrates from legacy single-account format on first access. @Observable @MainActor final class AccountManager { static let shared = AccountManager() + /// The currently active account (selected for unlock/session). + /// Desktop parity: `loginedAccount` in `AccountProvider.tsx`. private(set) var currentAccount: Account? + /// All stored accounts (encrypted, not yet unlocked). + /// Desktop parity: `allAccounts` in `AccountProvider.tsx`. + private(set) var allAccounts: [Account] = [] + private let crypto = CryptoManager.shared private let keychain = KeychainManager.shared + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager") private init() { - currentAccount = loadCachedAccount() + migrateFromSingleAccount() + allAccounts = loadAllAccounts() + + // Restore active account from UserDefaults (desktop: localStorage.last_logined_account) + let activeKey = UserDefaults.standard.string(forKey: Account.KeychainKey.activeAccountKey) + currentAccount = allAccounts.first(where: { $0.publicKey == activeKey }) + ?? allAccounts.first } // MARK: - Account Creation /// Creates a new account from a BIP39 mnemonic and password. /// Derives secp256k1 key pair, encrypts private key and seed phrase, saves to Keychain. + /// Desktop parity: `createAccount()` in `AccountProvider.tsx` — INSERT into accounts table. func createAccount(seedPhrase: [String], password: String) async throws -> Account { let account = try await Task.detached(priority: .userInitiated) { [crypto] in let (privateKey, publicKey) = try crypto.deriveKeyPair(from: seedPhrase) @@ -42,8 +60,8 @@ final class AccountManager { ) }.value - try saveAccount(account) - currentAccount = account + addAccount(account) + setActiveAccount(publicKey: account.publicKey) return account } @@ -69,8 +87,6 @@ final class AccountManager { /// Decrypts the private key and returns the raw hex string. /// Used to derive the handshake hash for server authentication. - private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager") - func decryptPrivateKey(password: String) async throws -> String { guard let account = currentAccount else { Self.logger.error("No account found for decryption") @@ -97,33 +113,119 @@ final class AccountManager { return privateKeyData.hexString } + // MARK: - Profile Update + /// Updates the display name and username on the current account. func updateProfile(displayName: String?, username: String?) { guard var account = currentAccount else { return } if let displayName { account.displayName = displayName } if let username { account.username = username } currentAccount = account - try? saveAccount(account) + addAccount(account) // Updates in array + persists to Keychain } - // MARK: - Persistence + // MARK: - Multi-Account Management - func saveAccount(_ account: Account) throws { - try keychain.saveCodable(account, forKey: Account.KeychainKey.account) + /// Adds an account to the stored array. Deduplicates by publicKey. + /// Desktop parity: `INSERT INTO accounts` in `AccountProvider.tsx`. + func addAccount(_ account: Account) { + var accounts = loadAllAccounts() + accounts.removeAll { $0.publicKey == account.publicKey } + accounts.append(account) + try? keychain.saveCodable(accounts, forKey: Account.KeychainKey.allAccounts) + allAccounts = accounts + + // Update legacy key for backward compatibility + if account.publicKey == activeAccountPublicKey { + try? keychain.saveCodable(account, forKey: Account.KeychainKey.account) + } } - func deleteAccount() throws { - try keychain.delete(forKey: Account.KeychainKey.account) - currentAccount = nil + /// Sets which account is active (shown on unlock, used for session). + /// Desktop parity: `selectAccountToLoginDice()` + `localStorage.last_logined_account`. + func setActiveAccount(publicKey: String) { + UserDefaults.standard.set(publicKey, forKey: Account.KeychainKey.activeAccountKey) + currentAccount = allAccounts.first(where: { $0.publicKey == publicKey }) + + // Update legacy key for backward compatibility + if let account = currentAccount { + try? keychain.saveCodable(account, forKey: Account.KeychainKey.account) + } } + /// The active account's public key from UserDefaults. + var activeAccountPublicKey: String? { + UserDefaults.standard.string(forKey: Account.KeychainKey.activeAccountKey) + } + + /// Removes a specific account from the stored array. + /// Desktop parity: `DELETE FROM accounts WHERE public_key = ?` — local only, no server packet. + func removeAccount(publicKey: String) { + var accounts = loadAllAccounts() + accounts.removeAll { $0.publicKey == publicKey } + try? keychain.saveCodable(accounts, forKey: Account.KeychainKey.allAccounts) + allAccounts = accounts + + // If removing the active account, switch to the next available + if activeAccountPublicKey == publicKey { + if let next = accounts.first { + setActiveAccount(publicKey: next.publicKey) + } else { + UserDefaults.standard.removeObject(forKey: Account.KeychainKey.activeAccountKey) + currentAccount = nil + try? keychain.delete(forKey: Account.KeychainKey.account) + } + } + } + + // MARK: - Queries + + /// Whether any accounts are stored. var hasAccount: Bool { - keychain.contains(key: Account.KeychainKey.account) + !allAccounts.isEmpty + } + + /// Whether multiple accounts are stored (enables account picker on unlock). + var hasMultipleAccounts: Bool { + allAccounts.count > 1 + } + + // MARK: - Legacy Compatibility + + /// Saves account to both legacy and new format. + func saveAccount(_ account: Account) throws { + addAccount(account) + } + + /// Deletes the current (active) account. + /// Backward-compatible wrapper around `removeAccount(publicKey:)`. + func deleteAccount() throws { + guard let key = currentAccount?.publicKey else { return } + removeAccount(publicKey: key) } // MARK: - Private - private func loadCachedAccount() -> Account? { - try? keychain.loadCodable(Account.self, forKey: Account.KeychainKey.account) + private func loadAllAccounts() -> [Account] { + (try? keychain.loadCodable([Account].self, forKey: Account.KeychainKey.allAccounts)) ?? [] + } + + /// One-time migration: if legacy `"currentAccount"` exists but `"allAccounts"` does not, + /// move the single account into the array format. + /// Desktop parity: desktop always used array in SQLite; iOS started with single entry. + private func migrateFromSingleAccount() { + // Already migrated + if keychain.contains(key: Account.KeychainKey.allAccounts) { return } + + // Check for legacy single account + guard let legacy = try? keychain.loadCodable(Account.self, forKey: Account.KeychainKey.account) else { return } + + Self.logger.info("Migrating single account to multi-account format") + + // Migrate to array format + try? keychain.saveCodable([legacy], forKey: Account.KeychainKey.allAccounts) + + // Set as active account + UserDefaults.standard.set(legacy.publicKey, forKey: Account.KeychainKey.activeAccountKey) } } diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index a5c6d5e..e522556 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -25,9 +25,6 @@ final class SessionManager { /// Desktop parity: exposed so chat list can suppress unread badges during sync. private(set) var syncBatchInProgress = false private var syncRequestInFlight = false - private var stalledSyncBatchCount = 0 - private let maxStalledSyncBatches = 12 - private var latestSyncBatchMessageTimestamp: Int64 = 0 private var pendingIncomingMessages: [PacketMessage] = [] private var isProcessingIncomingMessages = false private var pendingReadReceiptKeys: Set = [] @@ -191,6 +188,117 @@ final class SessionManager { registerOutgoingRetry(for: packet) } + /// Sends current user's avatar to a chat as a message attachment. + /// Desktop parity: `onClickCamera()` in `DialogInput.tsx` → loads avatar → attaches as AVATAR type + /// → `prepareAttachmentsToSend()` encrypts blob → uploads to transport → sends PacketMessage. + func sendAvatar(toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws { + guard let privKey = privateKeyHex, let hash = privateKeyHash else { + Self.logger.error("📤 Cannot send avatar — missing keys") + throw CryptoError.decryptionFailed + } + + // Load avatar from local storage as base64 (desktop: avatars[0].avatar) + guard let avatarBase64 = AvatarRepository.shared.loadAvatarBase64(publicKey: currentPublicKey) else { + Self.logger.error("📤 No avatar to send") + return + } + + let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) + let attachmentId = String((0..<8).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! }) + + // Generate ECDH keys + encrypt empty text (avatar messages can have empty text) + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: " ", + recipientPublicKeyHex: toPublicKey + ) + + // Desktop parity: attachment password = plainKeyAndNonce interpreted as UTF-8 string + // (same derivation as aesChachaKey: key.toString('utf-8') in useDialog.ts) + guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } + + // Encrypt avatar blob with the plainKeyAndNonce password (desktop: encodeWithPassword) + let avatarData = Data(avatarBase64.utf8) + let encryptedBlob = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + avatarData, + password: latin1String + ) + + // Upload encrypted blob to transport server (desktop: uploadFile) + let tag = try await TransportManager.shared.uploadFile( + id: attachmentId, + content: Data(encryptedBlob.utf8) + ) + + // Desktop parity: preview = "tag::blurhash" (blurhash optional, skip for now) + let preview = "\(tag)::" + + // Build aesChachaKey (same as regular messages) + let aesChachaPayload = Data(latin1String.utf8) + let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + aesChachaPayload, + password: privKey + ) + + // Build packet with avatar attachment + var packet = PacketMessage() + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = toPublicKey + packet.content = encrypted.content + packet.chachaKey = encrypted.chachaKey + packet.timestamp = timestamp + packet.privateKey = hash + packet.messageId = messageId + packet.aesChachaKey = aesChachaKey + packet.attachments = [ + MessageAttachment( + id: attachmentId, + preview: preview, + blob: "", // Desktop parity: blob cleared after upload + type: .avatar + ), + ] + + // Ensure dialog exists + let existingDialog = DialogRepository.shared.dialogs[toPublicKey] + let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "") + let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "") + DialogRepository.shared.ensureDialog( + opponentKey: toPublicKey, + title: title, + username: username, + myPublicKey: currentPublicKey + ) + + // Optimistic UI + let isConnected = ProtocolManager.shared.connectionState == .authenticated + let offlineAsSend = !isConnected + DialogRepository.shared.updateFromMessage( + packet, myPublicKey: currentPublicKey, decryptedText: " ", fromSync: offlineAsSend + ) + MessageRepository.shared.upsertFromMessagePacket( + packet, myPublicKey: currentPublicKey, decryptedText: " ", fromSync: offlineAsSend + ) + + if offlineAsSend { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error) + DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error) + } + + // Saved Messages — local only + if toPublicKey == currentPublicKey { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered) + return + } + + ProtocolManager.shared.sendPacket(packet) + registerOutgoingRetry(for: packet) + Self.logger.info("📤 Avatar sent to \(toPublicKey.prefix(12))… tag=\(tag)") + } + /// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog). func sendTypingIndicator(toPublicKey: String) { guard toPublicKey != currentPublicKey, @@ -231,8 +339,6 @@ final class SessionManager { lastTypingSentAt.removeAll() syncBatchInProgress = false syncRequestInFlight = false - stalledSyncBatchCount = 0 - latestSyncBatchMessageTimestamp = 0 pendingIncomingMessages.removeAll() isProcessingIncomingMessages = false pendingReadReceiptKeys.removeAll() @@ -357,6 +463,7 @@ final class SessionManager { self.username = packet.username AccountManager.shared.updateProfile(displayName: nil, username: packet.username) } + NotificationCenter.default.post(name: .profileDidUpdate, object: nil) } } @@ -381,14 +488,19 @@ final class SessionManager { userInfoPacket.privateKey = hash ProtocolManager.shared.sendPacket(userInfoPacket) } else { - Self.logger.debug("Skipping UserInfo — no profile data to send") + // No local profile — fetch from server (e.g. after import via seed phrase). + // Server may have our profile from a previous session on another device. + Self.logger.debug("Skipping UserInfo send — requesting own profile from server") + var searchPacket = PacketSearch() + searchPacket.privateKey = hash + searchPacket.search = self.currentPublicKey + ProtocolManager.shared.sendPacket(searchPacket) } // Reset sync state — if a previous connection dropped mid-sync, // syncRequestInFlight would stay true and block all future syncs. self.syncRequestInFlight = false self.syncBatchInProgress = false - self.stalledSyncBatchCount = 0 self.pendingIncomingMessages.removeAll() self.isProcessingIncomingMessages = false @@ -428,54 +540,22 @@ final class SessionManager { switch packet.status { case .batchStart: self.syncBatchInProgress = true - self.stalledSyncBatchCount = 0 - self.latestSyncBatchMessageTimestamp = 0 Self.logger.debug("SYNC BATCH_START") case .batchEnd: - // Desktop/Android parity: never advance sync cursor - // before all inbound message tasks from this batch finish. - let queueDrained = await self.waitForInboundQueueToDrain() - if !queueDrained { - Self.logger.warning("SYNC BATCH_END timed out waiting inbound queue; requesting next batch with local cursor") - } - - let localCursor = self.loadLastSyncTimestamp() + // Desktop parity (useSynchronize.ts): await whenFinish() then + // save server cursor and request next batch. + await self.waitForInboundQueueToDrain() let serverCursor = self.normalizeSyncTimestamp(packet.timestamp) - let batchCursor = self.latestSyncBatchMessageTimestamp - let nextCursor = max(localCursor, max(serverCursor, batchCursor)) - - if nextCursor > localCursor { - self.saveLastSyncTimestamp(nextCursor) - self.stalledSyncBatchCount = 0 - } else { - self.stalledSyncBatchCount += 1 - } - - if self.stalledSyncBatchCount >= self.maxStalledSyncBatches { - Self.logger.debug("SYNC stopped after stalled batches") - self.syncBatchInProgress = false - self.flushPendingReadReceipts() - DialogRepository.shared.reconcileDeliveryStatuses() - DialogRepository.shared.reconcileUnreadCounts() - self.stalledSyncBatchCount = 0 - // Refresh user info now that sync is done (desktop parity: lazy per-component). - Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(300)) - await self?.refreshOnlineStatusForAllDialogs() - } - return - } - - self.flushPendingReadReceipts() - self.requestSynchronize(cursor: nextCursor) + self.saveLastSyncTimestamp(serverCursor) + Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)") + self.requestSynchronize(cursor: serverCursor) case .notNeeded: self.syncBatchInProgress = false self.flushPendingReadReceipts() DialogRepository.shared.reconcileDeliveryStatuses() DialogRepository.shared.reconcileUnreadCounts() - self.stalledSyncBatchCount = 0 Self.logger.debug("SYNC NOT_NEEDED") // Refresh user info now that sync is done. Task { @MainActor [weak self] in @@ -485,6 +565,65 @@ final class SessionManager { } } } + + proto.onDeviceNewReceived = { [weak self] packet in + Task { @MainActor in + self?.handleDeviceNewLogin(packet) + } + } + } + + private func handleDeviceNewLogin(_ packet: PacketDeviceNew) { + let myKey = currentPublicKey + guard !myKey.isEmpty else { return } + + // Desktop parity: dotCenterIfNeeded(deviceId, 12, 4) + let truncId: String + if packet.deviceId.count > 12 { + truncId = "\(packet.deviceId.prefix(4))...\(packet.deviceId.suffix(4))" + } else { + truncId = packet.deviceId + } + + // Desktop parity: useDeviceMessage.ts messageTemplate + let text = """ + **Attempt to login from a new device** + + We detected a login to your account from **\(packet.ipAddress)** a new device **by seed phrase**. If this was you, you can safely ignore this message. + + **Arch:** \(packet.deviceOs) + **IP:** \(packet.ipAddress) + **Device:** \(packet.deviceName) + **ID:** \(truncId) + """ + + let safeKey = SystemAccounts.safePublicKey + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) + let messageId = UUID().uuidString + + // Desktop parity: Safe account has verified: 1 (public figure/brand) + DialogRepository.shared.ensureDialog( + opponentKey: safeKey, + title: SystemAccounts.safeTitle, + username: "safe", + verified: 1, + myPublicKey: myKey + ) + + var fakePacket = PacketMessage() + fakePacket.fromPublicKey = safeKey + fakePacket.toPublicKey = myKey + fakePacket.messageId = messageId + fakePacket.timestamp = timestamp + + DialogRepository.shared.updateFromMessage( + fakePacket, myPublicKey: myKey, + decryptedText: text, fromSync: false, isNewMessage: true + ) + MessageRepository.shared.upsertFromMessagePacket( + fakePacket, myPublicKey: myKey, + decryptedText: text, fromSync: false + ) } private func enqueueIncomingMessage(_ packet: PacketMessage) { @@ -516,36 +655,75 @@ final class SessionManager { let currentPrivateKeyHex = self.privateKeyHex let currentPrivateKeyHash = self.privateKeyHash - guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { - Self.logger.debug( - "Skipping unsupported message packet: from=\(packet.fromPublicKey), to=\(packet.toPublicKey)" - ) - return - } - let fromMe = packet.fromPublicKey == myKey + + guard Self.isSupportedDirectMessagePacket(packet, ownKey: myKey) else { return } + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let wasKnownBefore = MessageRepository.shared.hasMessage(packet.messageId) - let decryptedText = Self.decryptIncomingMessage( + let decryptResult = Self.decryptIncomingMessage( packet: packet, myPublicKey: myKey, privateKeyHex: currentPrivateKeyHex ) - guard let text = decryptedText else { - Self.logger.error( - "Incoming message dropped: \(packet.messageId), own=\(packet.fromPublicKey == myKey)" - ) - return + guard let result = decryptResult else { return } + let text = result.text + + // Desktop parity: decrypt MESSAGES-type attachment blobs inline. + var processedPacket = packet + if let keyData = result.rawKeyData { + let attachmentPassword = String(decoding: keyData, as: UTF8.self) + for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { + let blob = processedPacket.attachments[i].blob + if !blob.isEmpty, + let decrypted = try? CryptoManager.shared.decryptWithPassword(blob, password: attachmentPassword), + let decryptedString = String(data: decrypted, encoding: .utf8) { + processedPacket.attachments[i].blob = decryptedString + } + } + + // Desktop parity: auto-download AVATAR attachments from transport server. + // Flow: extract tag from preview → download from transport → decrypt with chacha key → save. + let crypto = CryptoManager.shared + for attachment in processedPacket.attachments where attachment.type == .avatar { + let senderKey = packet.fromPublicKey + let preview = attachment.preview + // Desktop parity: preview = "tag::blurhash" + let tag = preview.components(separatedBy: "::").first ?? preview + guard !tag.isEmpty else { continue } + let password = attachmentPassword + + Task { + do { + let encryptedData = try await TransportManager.shared.downloadFile(tag: tag) + let encryptedString = String(decoding: encryptedData, as: UTF8.self) + // Decrypt with the same password used for MESSAGES attachments + let decryptedData = try crypto.decryptWithPassword( + encryptedString, password: password + ) + // Decrypted data is the base64-encoded avatar image + if let base64String = String(data: decryptedData, encoding: .utf8) { + AvatarRepository.shared.saveAvatarFromBase64( + base64String, publicKey: senderKey + ) + } + } catch { + Self.logger.error( + "Failed to download/decrypt avatar from \(senderKey.prefix(12))…: \(error.localizedDescription)" + ) + } + } + } } DialogRepository.shared.updateFromMessage( - packet, myPublicKey: myKey, decryptedText: text, + processedPacket, myPublicKey: myKey, decryptedText: text, fromSync: syncBatchInProgress, isNewMessage: !wasKnownBefore ) MessageRepository.shared.upsertFromMessagePacket( - packet, + processedPacket, myPublicKey: myKey, decryptedText: text, fromSync: syncBatchInProgress @@ -574,7 +752,9 @@ final class SessionManager { // Desktop also skips system accounts and blocked users. let dialogIsActive = MessageRepository.shared.isDialogActive(opponentKey) let isSystem = SystemAccounts.isSystemAccount(opponentKey) - let shouldMarkRead = dialogIsActive && !isUserIdle && isAppInForeground && !isSystem + let idle = isUserIdle + let fg = isAppInForeground + let shouldMarkRead = dialogIsActive && !idle && fg && !isSystem if shouldMarkRead { DialogRepository.shared.markAsRead(opponentKey: opponentKey) @@ -591,12 +771,9 @@ final class SessionManager { } } - if syncBatchInProgress { - latestSyncBatchMessageTimestamp = max( - latestSyncBatchMessageTimestamp, - normalizeSyncTimestamp(packet.timestamp) - ) - } else { + // Desktop parity (useUpdateSyncTime.ts): no-op during SYNCHRONIZATION. + // Sync cursor is updated once at BATCH_END with the server's timestamp. + if !syncBatchInProgress { saveLastSyncTimestamp(packet.timestamp) } } @@ -613,29 +790,18 @@ final class SessionManager { } } - private func waitForInboundQueueToDrain(timeoutMs: UInt64 = 5_000) async -> Bool { + /// Desktop parity (dialogQueue.ts `whenFinish`): waits indefinitely for all + /// enqueued message tasks to complete before advancing the sync cursor. + private func waitForInboundQueueToDrain() async { // Fast path: already drained if !isProcessingIncomingMessages && pendingIncomingMessages.isEmpty { - return true + return } - // Event-based: wait for signal or timeout - let drained = await withTaskGroup(of: Bool.self) { group in - group.addTask { @MainActor in - await withCheckedContinuation { continuation in - self.drainContinuations.append(continuation) - } - return true - } - group.addTask { - try? await Task.sleep(for: .milliseconds(timeoutMs)) - return false - } - let result = await group.next() ?? false - group.cancelAll() - return result + // Event-based: wait for signalQueueDrained() + await withCheckedContinuation { continuation in + self.drainContinuations.append(continuation) } - return drained } private var syncCursorKey: String { @@ -705,49 +871,49 @@ final class SessionManager { raw < 1_000_000_000_000 ? raw * 1000 : raw } + /// Returns (decryptedText, rawKeyData) where rawKeyData can be used for attachment blob decryption. private static func decryptIncomingMessage( packet: PacketMessage, myPublicKey: String, privateKeyHex: String? - ) -> String? { - guard let privateKeyHex, - !packet.content.isEmpty - else { - return nil - } + ) -> (text: String, rawKeyData: Data?)? { + let isOwnMessage = packet.fromPublicKey == myPublicKey - // Android parity for own sync packets: prefer aesChachaKey if present. - if packet.fromPublicKey == myPublicKey, - !packet.aesChachaKey.isEmpty, - let decryptedPayload = try? CryptoManager.shared.decryptWithPassword( - packet.aesChachaKey, - password: privateKeyHex - ) - { - let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload) - if let text = try? MessageCrypto.decryptIncomingWithPlainKey( - ciphertext: packet.content, - plainKeyAndNonce: keyAndNonce - ) { - return text + guard let privateKeyHex, !packet.content.isEmpty else { return nil } + + // Own sync packets: prefer aesChachaKey (PBKDF2+AES encrypted key+nonce). + if isOwnMessage, !packet.aesChachaKey.isEmpty { + do { + let decryptedPayload = try CryptoManager.shared.decryptWithPassword( + packet.aesChachaKey, + password: privateKeyHex + ) + let keyAndNonce = MessageCrypto.androidUtf8BytesToLatin1Bytes(decryptedPayload) + let text = try MessageCrypto.decryptIncomingWithPlainKey( + ciphertext: packet.content, + plainKeyAndNonce: keyAndNonce + ) + return (text, keyAndNonce) + } catch { + // Fall through to ECDH path } - Self.logger.debug("Own message fallback: aesChachaKey decoded but payload decryption failed for \(packet.messageId)") - } else if packet.fromPublicKey == myPublicKey, !packet.aesChachaKey.isEmpty { - Self.logger.debug("Own message fallback: failed to decode aesChachaKey for \(packet.messageId)") } - guard !packet.chachaKey.isEmpty else { - return nil - } + // ECDH path (works for opponent messages, may work for own if chachaKey targets us) + guard !packet.chachaKey.isEmpty else { return nil } do { - return try MessageCrypto.decryptIncoming( + let text = try MessageCrypto.decryptIncoming( ciphertext: packet.content, encryptedKey: packet.chachaKey, myPrivateKeyHex: privateKeyHex ) + let rawKeyData = try? MessageCrypto.extractDecryptedKeyData( + encryptedKey: packet.chachaKey, + myPrivateKeyHex: privateKeyHex + ) + return (text, rawKeyData) } catch { - Self.logger.error("Message decryption failed: \(error.localizedDescription)") return nil } } @@ -861,13 +1027,36 @@ final class SessionManager { /// Persistent handler for ALL search results — updates dialog names/usernames from server data. /// This runs independently of ChatListViewModel's search UI handler. + /// Also detects own profile in search results and updates SessionManager + AccountManager. private func setupUserInfoSearchHandler() { userInfoSearchHandlerToken = ProtocolManager.shared.addSearchResultHandler { [weak self] packet in Task { @MainActor [weak self] in - guard self != nil else { return } + guard let self else { return } + let ownKey = self.currentPublicKey for user in packet.users { guard !user.publicKey.isEmpty else { continue } Self.logger.debug("🔍 Search result: \(user.publicKey.prefix(12))… title='\(user.title)' online=\(user.online) verified=\(user.verified)") + + // Own profile from server — update local profile if we have empty data + if user.publicKey == ownKey { + var updated = false + if !user.title.isEmpty && self.displayName.isEmpty { + self.displayName = user.title + AccountManager.shared.updateProfile(displayName: user.title, username: nil) + Self.logger.info("Own profile restored from server: title='\(user.title)'") + updated = true + } + if !user.username.isEmpty && self.username.isEmpty { + self.username = user.username + AccountManager.shared.updateProfile(displayName: nil, username: user.username) + Self.logger.info("Own profile restored from server: username='\(user.username)'") + updated = true + } + if updated { + NotificationCenter.default.post(name: .profileDidUpdate, object: nil) + } + } + // Update user info + online status from search results DialogRepository.shared.updateUserInfo( publicKey: user.publicKey, @@ -898,7 +1087,7 @@ final class SessionManager { throw CryptoError.encryptionFailed } let aesChachaPayload = Data(latin1String.utf8) - let aesChachaKey = try CryptoManager.shared.encryptWithPassword( + let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( aesChachaPayload, password: privateKeyHex ) @@ -984,11 +1173,14 @@ final class SessionManager { private func sendReadReceipt(toPublicKey: String, force: Bool) { let normalized = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + let connState = ProtocolManager.shared.connectionState guard normalized != currentPublicKey, !normalized.isEmpty, let hash = privateKeyHash, - ProtocolManager.shared.connectionState == .authenticated - else { return } + connState == .authenticated + else { + return + } let now = Int64(Date().timeIntervalSince1970 * 1000) if !force { diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift index e330717..1dd72b9 100644 --- a/Rosetta/DesignSystem/Components/AvatarView.swift +++ b/Rosetta/DesignSystem/Components/AvatarView.swift @@ -23,6 +23,8 @@ struct AvatarView: View { let size: CGFloat var isOnline: Bool = false var isSavedMessages: Bool = false + /// Optional avatar photo. When non-nil, displayed as a circular image. + var image: UIImage? = nil /// Override for the online-indicator border (matches row background). var onlineBorderColor: Color? @@ -49,19 +51,24 @@ struct AvatarView: View { var body: some View { ZStack { - if isSavedMessages { + if let image { + // Avatar photo — circular crop + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipShape(Circle()) + } else if isSavedMessages { Circle().fill(RosettaColors.primaryBlue) - } else { - // Mantine "light" variant: opaque base + semi-transparent tint - Circle().fill(colorScheme == .dark ? Self.mantineDarkBody : .white) - Circle().fill(avatarPair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10)) - } - if isSavedMessages { Image(systemName: "bookmark.fill") .font(.system(size: fontSize, weight: .semibold)) .foregroundStyle(.white) } else { + // Mantine "light" variant: opaque base + semi-transparent tint + Circle().fill(colorScheme == .dark ? Self.mantineDarkBody : .white) + Circle().fill(avatarPair.tint.opacity(colorScheme == .dark ? 0.15 : 0.10)) + Text(initials) .font(.system(size: fontSize, weight: .bold, design: .rounded)) .foregroundStyle(textColor) diff --git a/Rosetta/DesignSystem/Components/ButtonStyles.swift b/Rosetta/DesignSystem/Components/ButtonStyles.swift index ecaf931..af950f3 100644 --- a/Rosetta/DesignSystem/Components/ButtonStyles.swift +++ b/Rosetta/DesignSystem/Components/ButtonStyles.swift @@ -68,6 +68,53 @@ struct RosettaPrimaryButtonStyle: ButtonStyle { } } +// MARK: - Settings Row (Telegram-like instant press highlight) + +enum SettingsRowPosition { + case alone, top, middle, bottom +} + +/// Instant press highlight using DragGesture — bypasses ScrollView's touch delay. +private struct SettingsHighlightModifier: ViewModifier { + let position: SettingsRowPosition + let cornerRadius: CGFloat + + @GestureState private var isPressed = false + + func body(content: Content) -> some View { + let shape = shape(for: position) + content + .buttonStyle(.plain) + .background(shape.fill(isPressed ? Color.white.opacity(0.08) : Color.clear)) + .clipShape(shape) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .updating($isPressed) { value, state, _ in + state = abs(value.translation.height) < 10 + } + ) + } + + private func shape(for position: SettingsRowPosition) -> UnevenRoundedRectangle { + switch position { + case .alone: + UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius, bottomTrailingRadius: cornerRadius, topTrailingRadius: cornerRadius, style: .continuous) + case .top: + UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: cornerRadius, style: .continuous) + case .middle: + UnevenRoundedRectangle(topLeadingRadius: 0, bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: 0, style: .continuous) + case .bottom: + UnevenRoundedRectangle(topLeadingRadius: 0, bottomLeadingRadius: cornerRadius, bottomTrailingRadius: cornerRadius, topTrailingRadius: 0, style: .continuous) + } + } +} + +extension View { + func settingsHighlight(position: SettingsRowPosition = .alone, cornerRadius: CGFloat = 26) -> some View { + modifier(SettingsHighlightModifier(position: position, cornerRadius: cornerRadius)) + } +} + // MARK: - Shine Overlay (Telegram-style color sweep) // // Instead of a white glare, a lighter/cyan tint of the button's own colour diff --git a/Rosetta/DesignSystem/Components/ChatTextInput.swift b/Rosetta/DesignSystem/Components/ChatTextInput.swift index 054998c..42eca36 100644 --- a/Rosetta/DesignSystem/Components/ChatTextInput.swift +++ b/Rosetta/DesignSystem/Components/ChatTextInput.swift @@ -11,7 +11,6 @@ final class KeyboardTrackingView: UIView { var onHeightChange: ((CGFloat) -> Void)? private var observation: NSKeyValueObservation? - private var superviewHeightObservation: NSKeyValueObservation? override init(frame: CGRect) { super.init(frame: .init(x: 0, y: 0, width: frame.width, height: 0)) @@ -29,30 +28,34 @@ final class KeyboardTrackingView: UIView { super.didMoveToSuperview() observation?.invalidate() observation = nil - superviewHeightObservation?.invalidate() - superviewHeightObservation = nil guard let sv = superview else { return } + // Only observe .center — .bounds fires simultaneously for the same + // position change, doubling KVO callbacks with no new information. observation = sv.observe(\.center, options: [.new]) { [weak self] view, _ in self?.reportHeight(from: view) } - superviewHeightObservation = sv.observe(\.bounds, options: [.new]) { [weak self] view, _ in - self?.reportHeight(from: view) - } } + private var lastReportedHeight: CGFloat = -1 + private func reportHeight(from hostView: UIView) { guard let window = hostView.window else { return } let screenHeight = window.screen.bounds.height let hostFrame = hostView.convert(hostView.bounds, to: nil) let keyboardHeight = max(0, screenHeight - hostFrame.origin.y) - onHeightChange?(keyboardHeight) + // Throttle: only fire callback when rounded height actually changes. + // Sub-point changes are invisible but still trigger full SwiftUI layout. + // This halves the number of body evaluations during interactive dismiss. + let rounded = round(keyboardHeight) + guard abs(rounded - lastReportedHeight) > 1 else { return } + lastReportedHeight = rounded + onHeightChange?(rounded) } deinit { observation?.invalidate() - superviewHeightObservation?.invalidate() } } diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift index 596df78..fae92d3 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -1,5 +1,25 @@ import SwiftUI +// MARK: - Settings Card (flat #1C1C1E background, no border/blur) + +struct SettingsCard: View { + let content: () -> Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var body: some View { + content() + .background( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(Color(red: 28/255, green: 28/255, blue: 30/255)) + ) + } +} + +// MARK: - Glass Card (material blur + border) + struct GlassCard: View { let cornerRadius: CGFloat let fillOpacity: Double diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index 4c6c63b..c1c5b2f 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -8,14 +8,17 @@ import UIKit /// - `keyboardPadding`: bottom padding to apply when keyboard is visible /// /// Animation strategy: -/// - Notification (show/hide): CADisplayLink interpolates `keyboardPadding` at 60fps -/// with ease-out curve. Small incremental updates keep LazyVStack stable (no cell -/// recycling, no gaps between bubbles). -/// - KVO (interactive dismiss): raw assignment at 60fps — already smooth. +/// - Notification (show/hide): A hidden UIView is animated with the keyboard's +/// exact `UIViewAnimationCurve` (rawValue 7) inside the same Core Animation +/// transaction. CADisplayLink samples the presentation layer at 60fps, +/// giving pixel-perfect curve sync. Cubic bezier fallback if no window. +/// - KVO (interactive dismiss): raw assignment at 30fps via coalescing. /// - NO `withAnimation` / `.animation()` — these cause LazyVStack cell recycling gaps. @MainActor final class KeyboardTracker: ObservableObject { + static let shared = KeyboardTracker() + /// Bottom padding — updated incrementally at display refresh rate. @Published private(set) var keyboardPadding: CGFloat = 0 @@ -25,17 +28,37 @@ final class KeyboardTracker: ObservableObject { private var cancellables = Set() private var lastNotificationPadding: CGFloat = 0 - // CADisplayLink-based animation state + // CADisplayLink-based animation state (notification-driven show/hide) private var displayLinkProxy: DisplayLinkProxy? private var animStartPadding: CGFloat = 0 private var animTargetPadding: CGFloat = 0 private var animStartTime: CFTimeInterval = 0 private var animDuration: CFTimeInterval = 0.25 + private var animTickCount = 0 + private var animationNumber = 0 + private var lastTickTime: CFTimeInterval = 0 + + // Presentation-layer sync: a hidden UIView animated with the keyboard's + // exact curve. Reading its presentation layer on each CADisplayLink tick + // gives us the real easing value — no guessing control points. + private var syncView: UIView? + private var usingSyncAnimation = false + + // Cubic bezier fallback (used when syncView is unavailable) + private var bezierP1x: CGFloat = 0.25 + private var bezierP1y: CGFloat = 0.1 + private var bezierP2x: CGFloat = 0.25 + private var bezierP2y: CGFloat = 1.0 + + // KVO coalescing — buffers rapid KVO updates and applies at 30fps + // instead of immediately on every callback (~60fps). Halves body evaluations. + private var kvoDisplayLink: DisplayLinkProxy? + private var pendingKVOPadding: CGFloat? /// Spring kept for potential future use (e.g., composer-only animation). static let keyboardSpring = Animation.spring(duration: 0.25, bounce: 0) - init() { + private init() { if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.keyWindow ?? scene.windows.first { let bottom = window.safeAreaInsets.bottom @@ -44,24 +67,35 @@ final class KeyboardTracker: ObservableObject { bottomInset = 34 } + // iOS 26+ handles keyboard natively — no custom tracking needed. + if #available(iOS 26, *) { return } + NotificationCenter.default .publisher(for: UIResponder.keyboardWillChangeFrameNotification) .sink { [weak self] in self?.handleNotification($0) } .store(in: &cancellables) } - /// Called from KVO — pixel-perfect interactive dismiss at 60fps. - /// Only applies DECREASING padding (swipe-to-dismiss). + /// Called from KVO — pixel-perfect interactive dismiss. + /// Buffers values and applies at 30fps via CADisplayLink coalescing + /// to reduce ChatDetailView.body evaluations during swipe-to-dismiss. func updateFromKVO(keyboardHeight: CGFloat) { + if #available(iOS 26, *) { return } guard !isAnimating else { return } if keyboardHeight <= 0 { + // Flush any pending KVO value and stop coalescing + flushPendingKVO() + stopKVOCoalescing() + if keyboardPadding != 0 { if pendingResetTask == nil { pendingResetTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .milliseconds(150)) guard let self, !Task.isCancelled else { return } - if self.keyboardPadding != 0 { self.keyboardPadding = 0 } + if self.keyboardPadding != 0 { + self.keyboardPadding = 0 + } } } } @@ -71,20 +105,61 @@ final class KeyboardTracker: ObservableObject { pendingResetTask?.cancel() pendingResetTask = nil - let newPadding = max(0, keyboardHeight - bottomInset) - guard newPadding < keyboardPadding else { return } + // Round to nearest 2pt — sub-point changes are invisible but still + // trigger full ChatDetailView.body evaluations. + let rawPadding = max(0, keyboardHeight - bottomInset) + let newPadding = round(rawPadding / 2) * 2 - if newPadding != keyboardPadding { - keyboardPadding = newPadding + // Only track decreasing padding (swipe-to-dismiss) + let current = pendingKVOPadding ?? keyboardPadding + guard newPadding < current else { return } + + // Buffer the value — will be applied by kvoDisplayLink at 30fps + pendingKVOPadding = newPadding + + // Start coalescing display link if not running + if kvoDisplayLink == nil { + kvoDisplayLink = DisplayLinkProxy(preferredFPS: 30) { [weak self] in + self?.applyPendingKVO() + } } } + /// Called by kvoDisplayLink at 30fps — applies buffered KVO value. + private func applyPendingKVO() { + guard let pending = pendingKVOPadding else { + // No pending value — stop the display link + stopKVOCoalescing() + return + } + pendingKVOPadding = nil + guard pending != keyboardPadding else { return } + keyboardPadding = pending + } + + /// Immediately applies any buffered KVO value (used when KVO stops). + private func flushPendingKVO() { + guard let pending = pendingKVOPadding else { return } + pendingKVOPadding = nil + if pending != keyboardPadding { + keyboardPadding = pending + } + } + + private func stopKVOCoalescing() { + kvoDisplayLink?.stop() + kvoDisplayLink = nil + } + private func handleNotification(_ notification: Notification) { guard let info = notification.userInfo, let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } isAnimating = true + // Stop KVO coalescing — notification animation takes over + flushPendingKVO() + stopKVOCoalescing() let screenHeight = UIScreen.main.bounds.height let keyboardTop = endFrame.origin.y @@ -96,59 +171,187 @@ final class KeyboardTracker: ObservableObject { let targetPadding = isVisible ? max(0, endHeight - bottomInset) : 0 let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 + let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0 let delta = targetPadding - lastNotificationPadding lastNotificationPadding = targetPadding - if abs(delta) > 1 { + // Guard: skip animation when target equals current padding (e.g., after + // interactive dismiss already brought padding to 0, the late notification + // would start a wasted 0→0 animation with ~14 no-op CADisplayLink ticks). + if abs(delta) > 1, targetPadding != keyboardPadding { // CADisplayLink interpolation: updates @Published at ~60fps. // Each frame is a small layout delta → LazyVStack handles it without // cell recycling → no gaps between message bubbles. - startPaddingAnimation(to: targetPadding, duration: duration) + startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw) + } else { + // Still snap to target in case of rounding differences + if keyboardPadding != targetPadding { + keyboardPadding = targetPadding + } } // Unblock KVO after animation + buffer. + let unblockDelay = max(duration, 0.05) + 0.15 Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(max(duration, 0.05) + 0.15)) + try? await Task.sleep(for: .seconds(unblockDelay)) self?.isAnimating = false } } // MARK: - CADisplayLink animation - private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval) { - displayLinkProxy?.stop() - + private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) { + animationNumber += 1 animStartPadding = keyboardPadding animTargetPadding = target - animStartTime = CACurrentMediaTime() + animStartTime = 0 animDuration = max(duration, 0.05) + animTickCount = 0 - displayLinkProxy = DisplayLinkProxy { [weak self] in - self?.animationTick() + // Primary: sync with the keyboard's exact curve via presentation layer. + // UIView.animate called HERE lands in the same Core Animation transaction + // as the keyboard notification — identical timing function and start time. + usingSyncAnimation = setupSyncAnimation(duration: duration, curveRaw: curveRaw) + + // Fallback: cubic bezier approximation (CSS "ease"). + if !usingSyncAnimation { + configureBezier(curveRaw: curveRaw) + } + + // Reuse existing display link to preserve vsync phase alignment. + // Creating a new CADisplayLink on each animation resets the phase, + // causing alternating frame intervals (15/18ms instead of steady 16.6ms). + if let proxy = displayLinkProxy { + proxy.isPaused = false + } else { + displayLinkProxy = DisplayLinkProxy { [weak self] in + self?.animationTick() + } } } - private func animationTick() { - let elapsed = CACurrentMediaTime() - animStartTime - let t = min(elapsed / animDuration, 1.0) + /// Starts a hidden UIView animation matching the keyboard's exact curve. + /// Returns true if sync animation was set up successfully. + private func setupSyncAnimation(duration: CFTimeInterval, curveRaw: Int) -> Bool { + guard let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }).first?.keyWindow else { + return false + } - // Ease-out cubic — closely matches iOS keyboard animation feel. - let eased = 1 - pow(1 - t, 3) + // Remove previous sync view completely — a fresh view + // guarantees clean presentation layer with no animation history. + // Reusing the same view causes Core Animation to coalesce/skip + // repeated 0→1 opacity animations on subsequent calls. + syncView?.layer.removeAllAnimations() + syncView?.removeFromSuperview() + + let view = UIView(frame: CGRect(x: -10, y: -10, width: 1, height: 1)) + view.alpha = 0 + window.addSubview(view) + syncView = view + + // UIView.AnimationOptions encodes the curve in bits 16-19. + // For rawValue 7 (private keyboard curve), this passes through to + // Core Animation with Apple's exact timing function. + let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16) + UIView.animate( + withDuration: duration, + delay: 0, + options: [options] + ) { + view.alpha = 1 + } + + return true + } + + private func animationTick() { + animTickCount += 1 + + let now = CACurrentMediaTime() + lastTickTime = now + + // Get eased fraction — either from presentation layer (exact) or bezier (fallback). + let eased: CGFloat + var isComplete = false + + if usingSyncAnimation, let presentation = syncView?.layer.presentation() { + let fraction = CGFloat(presentation.opacity) + eased = fraction + isComplete = fraction >= 0.999 + } else { + // Bezier fallback + if animStartTime == 0 { animStartTime = now } + let elapsed = now - animStartTime + let t = min(elapsed / animDuration, 1.0) + eased = cubicBezierEase(t) + isComplete = t >= 1.0 + } // Round to nearest 1pt — sub-point changes are invisible but still // trigger full SwiftUI layout passes. Skipping them reduces render cost. let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased let rounded = round(raw) - if t >= 1.0 { + if isComplete || animTickCount > 30 { keyboardPadding = animTargetPadding - displayLinkProxy?.stop() - displayLinkProxy = nil + // Pause instead of invalidate — preserves vsync phase for next animation. + displayLinkProxy?.isPaused = true + lastTickTime = 0 } else if rounded != keyboardPadding { keyboardPadding = rounded } } + + // MARK: - Cubic bezier fallback + + /// Maps `UIViewAnimationCurve` rawValue to cubic bezier control points. + private func configureBezier(curveRaw: Int) { + switch UIView.AnimationCurve(rawValue: curveRaw) { + case .easeIn: + bezierP1x = 0.42; bezierP1y = 0 + bezierP2x = 1.0; bezierP2y = 1.0 + case .easeOut: + bezierP1x = 0; bezierP1y = 0 + bezierP2x = 0.58; bezierP2y = 1.0 + case .linear: + bezierP1x = 0; bezierP1y = 0 + bezierP2x = 1.0; bezierP2y = 1.0 + default: + // CSS "ease" — closest known approximation of curve 7. + bezierP1x = 0.25; bezierP1y = 0.1 + bezierP2x = 0.25; bezierP2y = 1.0 + } + } + + /// Evaluates the configured cubic bezier at linear time `x` (0…1). + /// Uses Newton–Raphson for fast convergence (~4 iterations). + private func cubicBezierEase(_ x: CGFloat) -> CGFloat { + guard x > 0 else { return 0 } + guard x < 1 else { return 1 } + var t = x + for _ in 0..<8 { + let bx = bezierValue(t, p1: bezierP1x, p2: bezierP2x) + let dx = bezierDerivative(t, p1: bezierP1x, p2: bezierP2x) + guard abs(dx) > 1e-6 else { break } + t -= (bx - x) / dx + t = min(max(t, 0), 1) + } + return bezierValue(t, p1: bezierP1y, p2: bezierP2y) + } + + /// Single axis of cubic bezier: B(t) = 3(1−t)²t·p1 + 3(1−t)t²·p2 + t³ + private func bezierValue(_ t: CGFloat, p1: CGFloat, p2: CGFloat) -> CGFloat { + let mt = 1 - t + return 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t + } + + /// Derivative of single bezier axis: B'(t) + private func bezierDerivative(_ t: CGFloat, p1: CGFloat, p2: CGFloat) -> CGFloat { + let mt = 1 - t + return 3 * mt * mt * p1 + 6 * mt * t * (p2 - p1) + 3 * t * t * (1 - p2) + } } // MARK: - CADisplayLink wrapper (avoids @objc requirement on @MainActor class) @@ -157,13 +360,14 @@ private class DisplayLinkProxy { private var callback: (() -> Void)? private var displayLink: CADisplayLink? - init(callback: @escaping () -> Void) { + /// - Parameter preferredFPS: Target frame rate. 60 for notification animation, + /// 30 for KVO coalescing (halves body evaluations during interactive dismiss). + init(preferredFPS: Int = 60, callback: @escaping () -> Void) { self.callback = callback self.displayLink = CADisplayLink(target: self, selector: #selector(tick)) - // Cap at 60fps — keyboard animation doesn't need 120Hz, and each tick - // triggers a full ChatDetailView body evaluation. 60fps halves the cost. + let fps = Float(preferredFPS) self.displayLink?.preferredFrameRateRange = CAFrameRateRange( - minimum: 30, maximum: 60, preferred: 60 + minimum: fps / 2, maximum: fps, preferred: fps ) self.displayLink?.add(to: .main, forMode: .common) } @@ -172,6 +376,11 @@ private class DisplayLinkProxy { callback?() } + var isPaused: Bool { + get { displayLink?.isPaused ?? true } + set { displayLink?.isPaused = newValue } + } + func stop() { displayLink?.invalidate() displayLink = nil diff --git a/Rosetta/DesignSystem/Components/RosettaLogoShape.swift b/Rosetta/DesignSystem/Components/RosettaLogoShape.swift new file mode 100644 index 0000000..ef5ff87 --- /dev/null +++ b/Rosetta/DesignSystem/Components/RosettaLogoShape.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// Stylized "R" logo shape from the desktop SVG. +/// Renders at any size as a vector — no bitmap artifacts. +struct RosettaLogoShape: Shape { + func path(in rect: CGRect) -> Path { + // Original SVG viewBox: 0 0 384 384 + let w = rect.width + let h = rect.height + let sx = w / 384.0 + let sy = h / 384.0 + + var path = Path() + + // Bottom-right piece (the "leg" + diagonal) + path.move(to: CGPoint(x: 254.16 * sx, y: 284.45 * sy)) + path.addLine(to: CGPoint(x: 288.41 * sx, y: 275.19 * sy)) + path.addCurve( + to: CGPoint(x: 337.41 * sx, y: 240.77 * sy), + control1: CGPoint(x: 310.0 * sx, y: 265.0 * sy), + control2: CGPoint(x: 326.0 * sx, y: 254.0 * sy) + ) + path.addCurve( + to: CGPoint(x: 369.26 * sx, y: 163.69 * sy), + control1: CGPoint(x: 358.63 * sx, y: 220.92 * sy), + control2: CGPoint(x: 369.26 * sx, y: 195.24 * sy) + ) + path.addLine(to: CGPoint(x: 369.26 * sx, y: 110.0 * sy)) + path.addLine(to: CGPoint(x: 249.55 * sx, y: 110.22 * sy)) + path.addLine(to: CGPoint(x: 249.55 * sx, y: 168.15 * sy)) + path.addCurve( + to: CGPoint(x: 226.15 * sx, y: 208.22 * sy), + control1: CGPoint(x: 249.55 * sx, y: 184.85 * sy), + control2: CGPoint(x: 241.75 * sx, y: 198.20 * sy) + ) + path.addCurve( + to: CGPoint(x: 159.01 * sx, y: 223.23 * sy), + control1: CGPoint(x: 210.55 * sx, y: 218.23 * sy), + control2: CGPoint(x: 188.18 * sx, y: 223.23 * sy) + ) + path.addLine(to: CGPoint(x: 134.07 * sx, y: 223.23 * sy)) + path.addLine(to: CGPoint(x: 134.07 * sx, y: 301.0 * sy)) + path.addLine(to: CGPoint(x: 206.65 * sx, y: 381.43 * sy)) + path.addLine(to: CGPoint(x: 344.77 * sx, y: 381.43 * sy)) + path.addLine(to: CGPoint(x: 254.16 * sx, y: 284.45 * sy)) + path.closeSubpath() + + // Top-left piece (the square block) + path.move(to: CGPoint(x: 248.42 * sx, y: 109.26 * sy)) + path.addLine(to: CGPoint(x: 248.42 * sx, y: 2.61 * sy)) + path.addLine(to: CGPoint(x: 14.77 * sx, y: 2.61 * sy)) + path.addLine(to: CGPoint(x: 14.77 * sx, y: 221.52 * sy)) + path.addLine(to: CGPoint(x: 132.94 * sx, y: 221.52 * sy)) + path.addLine(to: CGPoint(x: 132.94 * sx, y: 109.26 * sy)) + path.addLine(to: CGPoint(x: 248.42 * sx, y: 109.26 * sy)) + path.closeSubpath() + + return path + } +} diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift index 8f44e25..a4d9331 100644 --- a/Rosetta/DesignSystem/Components/RosettaTabBar.swift +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -3,7 +3,7 @@ import UIKit // MARK: - Tab -enum RosettaTab: CaseIterable, Sendable { +enum RosettaTab: String, CaseIterable, Sendable { case chats case calls case settings diff --git a/Rosetta/DesignSystem/Components/VerifiedBadge.swift b/Rosetta/DesignSystem/Components/VerifiedBadge.swift index e125204..ab48fbd 100644 --- a/Rosetta/DesignSystem/Components/VerifiedBadge.swift +++ b/Rosetta/DesignSystem/Components/VerifiedBadge.swift @@ -21,7 +21,7 @@ struct VerifiedBadge: View { var body: some View { if verified > 0 { - Image(systemName: "checkmark.seal.fill") + Image(systemName: iconName) .font(.system(size: size)) .foregroundStyle(resolvedColor) .onTapGesture { showExplanation = true } @@ -36,8 +36,28 @@ struct VerifiedBadge: View { // MARK: - Private + /// Desktop parity: different icon per verification level. + /// Level 1 = rosette (public figure), Level 2 = shield (Rosetta admin), + /// Level 3+ = arrow badge (group admin). + private var iconName: String { + switch verified { + case 2: + return "checkmark.shield.fill" + case 3...: + return "arrow.down.app.fill" + default: + return "checkmark.seal.fill" + } + } + + /// Desktop parity: level 2 (Rosetta admin) uses green, others use brand blue. private var resolvedColor: Color { if let badgeTint { return badgeTint } + if verified == 2 { + return colorScheme == .dark + ? RosettaColors.success // green + : RosettaColors.success + } return colorScheme == .dark ? RosettaColors.primaryBlue // #248AE6 : Color(hex: 0xACD2F9) // soft blue (light theme) diff --git a/Rosetta/Features/Auth/AuthCoordinator.swift b/Rosetta/Features/Auth/AuthCoordinator.swift index 5b275b3..6584a1e 100644 --- a/Rosetta/Features/Auth/AuthCoordinator.swift +++ b/Rosetta/Features/Auth/AuthCoordinator.swift @@ -2,12 +2,14 @@ import SwiftUI // MARK: - Auth Screen Enum -enum AuthScreen: Equatable { +enum AuthScreen: Equatable, Identifiable { case welcome case seedPhrase case confirmSeed case importSeed case setPassword + + var id: Self { self } } // MARK: - Auth Coordinator @@ -15,16 +17,31 @@ enum AuthScreen: Equatable { struct AuthCoordinator: View { let onAuthComplete: () -> Void var onBackToUnlock: (() -> Void)? + var initialScreen: AuthScreen = .welcome - @State private var currentScreen: AuthScreen = .welcome + @State private var currentScreen: AuthScreen @State private var seedPhrase: [String] = [] - @State private var isImportMode = false + @State private var isImportMode: Bool @State private var navigationDirection: NavigationDirection = .forward @State private var swipeOffset: CGFloat = 0 @State private var fadeOverlay: Bool = false + /// Tracks whether the current drag was identified as a valid horizontal swipe. + @State private var isSwipeActive = false + + init( + onAuthComplete: @escaping () -> Void, + onBackToUnlock: (() -> Void)? = nil, + initialScreen: AuthScreen = .welcome + ) { + self.onAuthComplete = onAuthComplete + self.onBackToUnlock = onBackToUnlock + self.initialScreen = initialScreen + _currentScreen = State(initialValue: initialScreen) + _isImportMode = State(initialValue: initialScreen == .importSeed) + } private var canSwipeBack: Bool { - if currentScreen == .welcome { + if currentScreen == initialScreen { return onBackToUnlock != nil } return true @@ -70,15 +87,7 @@ struct AuthCoordinator: View { .allowsHitTesting(fadeOverlay) .animation(.easeInOut(duration: 0.035), value: fadeOverlay) } - .overlay(alignment: .leading) { - if canSwipeBack { - Color.clear - .frame(width: 20) - .contentShape(Rectangle()) - .padding(.top, 60) - .gesture(swipeBackGesture(screenWidth: screenWidth)) - } - } + .simultaneousGesture(swipeBackGesture(screenWidth: screenWidth)) .preferredColorScheme(.dark) } } @@ -104,7 +113,13 @@ private extension AuthCoordinator { SeedPhraseView( seedPhrase: $seedPhrase, onContinue: { navigateTo(.confirmSeed) }, - onBack: { navigateBack(to: .welcome) } + onBack: { + if initialScreen == .seedPhrase { + onBackToUnlock?() + } else { + navigateBack(to: .welcome) + } + } ) case .confirmSeed: @@ -124,7 +139,13 @@ private extension AuthCoordinator { isImportMode = true navigateTo(.setPassword) }, - onBack: { navigateBack(to: .welcome) } + onBack: { + if initialScreen == .importSeed { + onBackToUnlock?() + } else { + navigateBack(to: .welcome) + } + } ) case .setPassword: @@ -149,11 +170,19 @@ private extension AuthCoordinator { case .welcome: EmptyView() case .seedPhrase: - WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock) + if initialScreen == .seedPhrase { + EmptyView() + } else { + WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock) + } case .confirmSeed: SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {}) case .importSeed: - WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock) + if initialScreen == .importSeed { + EmptyView() + } else { + WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock) + } case .setPassword: if isImportMode { ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {}) @@ -193,11 +222,28 @@ private extension AuthCoordinator { private extension AuthCoordinator { func swipeBackGesture(screenWidth: CGFloat) -> some Gesture { - DragGesture(minimumDistance: 10) + DragGesture(minimumDistance: 15) .onChanged { value in + guard canSwipeBack else { return } + + // On first significant movement, determine if this is a horizontal rightward swipe. + // Ignore vertical drags (let ScrollView handle those). + if !isSwipeActive && swipeOffset == 0 { + let dx = abs(value.translation.width) + let dy = abs(value.translation.height) + guard dx > dy, value.translation.width > 0 else { return } + isSwipeActive = true + } + guard isSwipeActive else { return } swipeOffset = max(value.translation.width, 0) } .onEnded { value in + defer { isSwipeActive = false } + guard canSwipeBack, swipeOffset > 0 else { + swipeOffset = 0 + return + } + let shouldGoBack = value.translation.width > 100 || value.predictedEndTranslation.width > 200 @@ -206,9 +252,9 @@ private extension AuthCoordinator { swipeOffset = screenWidth } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - if currentScreen == .welcome { + if currentScreen == initialScreen { // Don't reset swipeOffset — keep screen offscreen - // while parent performs its own transition to unlock. + // while parent performs its own transition. onBackToUnlock?() } else { swipeOffset = 0 diff --git a/Rosetta/Features/Auth/ImportSeedPhraseView.swift b/Rosetta/Features/Auth/ImportSeedPhraseView.swift index a720f8a..ab60be0 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -18,24 +18,30 @@ struct ImportSeedPhraseView: View { VStack(spacing: 0) { AuthNavigationBar(onBack: onBack) - ScrollView(showsIndicators: false) { - VStack(spacing: 24) { - headerSection - pasteButton - wordGrid - errorSection - } - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 100) - } - .scrollDismissesKeyboard(.interactively) - .onTapGesture(count: 1) { focusedWordIndex = nil } - .simultaneousGesture(TapGesture().onEnded {}) + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + VStack(spacing: 24) { + headerSection + pasteButton + wordGrid + errorSection + } + .padding(.horizontal, 24) + .padding(.top, 16) - continueButton - .padding(.horizontal, 24) - .padding(.bottom, 16) + Spacer(minLength: 24) + + continueButton + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + .frame(minHeight: geometry.size.height) + } + .scrollDismissesKeyboard(.interactively) + .onTapGesture(count: 1) { focusedWordIndex = nil } + .simultaneousGesture(TapGesture().onEnded {}) + } } } } diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 193bc69..298fe47 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -314,7 +314,7 @@ private struct SecureToggleField: UIViewRepresentable { ) // Eye toggle button — entirely UIKit, no SwiftUI state involved - let config = UIImage.SymbolConfiguration(pointSize: 16) + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin) let eyeButton = UIButton(type: .system) eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal) eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5) @@ -378,7 +378,7 @@ private struct SecureToggleField: UIViewRepresentable { // Update eye icon let imageName = tf.isSecureTextEntry ? "eye" : "eye.slash" - let config = UIImage.SymbolConfiguration(pointSize: 16) + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin) let button = tf.rightView as? UIButton button?.setImage( UIImage(systemName: imageName, withConfiguration: config), diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift index 794ad39..cce3ee5 100644 --- a/Rosetta/Features/Auth/UnlockView.swift +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -1,3 +1,4 @@ +import LocalAuthentication import SwiftUI /// Password unlock screen matching rosetta-android design. @@ -8,7 +9,12 @@ struct UnlockView: View { @State private var password = "" @State private var isUnlocking = false @State private var errorMessage: String? - @State private var showPassword = false + // Biometric unlock + @State private var biometricTriggered = false + // Avatar + @State private var avatarImage: UIImage? + // Multi-account picker (desktop parity: DiceDropdown) + @State private var showAccountPicker = false // Staggered fade-in animation @State private var showAvatar = false @@ -46,6 +52,11 @@ struct UnlockView: View { return shortPublicKey } + /// Whether biometric unlock is available and enabled for this account. + private var canUseBiometric: Bool { + BiometricAuthManager.shared.canUseBiometric(forAccount: publicKey) + } + var body: some View { ZStack { RosettaColors.authBackground @@ -60,19 +71,34 @@ struct UnlockView: View { initials: avatarText, colorIndex: avatarColorIndex, size: 100, - isSavedMessages: false + isSavedMessages: false, + image: avatarImage ) .opacity(showAvatar ? 1 : 0) .scaleEffect(showAvatar ? 1 : 0.8) Spacer().frame(height: 20) - // Display name (or short public key fallback) - Text(displayTitle) - .font(.system(size: 24, weight: .bold)) - .foregroundStyle(.white) - .opacity(showTitle ? 1 : 0) - .offset(y: showTitle ? 0 : 8) + // Display name (or short public key fallback) + account switcher + HStack(spacing: 8) { + Text(displayTitle) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.white) + + // Desktop parity: DiceDropdown arrow icon for multi-account + if AccountManager.shared.hasMultipleAccounts { + Button { + showAccountPicker = true + } label: { + Image(systemName: "arrow.left.arrow.right") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.6)) + } + .buttonStyle(.plain) + } + } + .opacity(showTitle ? 1 : 0) + .offset(y: showTitle ? 0 : 8) Spacer().frame(height: 8) @@ -99,6 +125,14 @@ struct UnlockView: View { .opacity(showButton ? 1 : 0) .offset(y: showButton ? 0 : 12) + // Biometric button — shown when Face ID/Touch ID is available + if canUseBiometric { + biometricButton + .padding(.top, 16) + .opacity(showButton ? 1 : 0) + .offset(y: showButton ? 0 : 12) + } + Spacer().frame(height: 60) // Footer — "You can also recover your password or create a new account." @@ -110,7 +144,23 @@ struct UnlockView: View { } .scrollDismissesKeyboard(.interactively) } - .onAppear { startAnimations() } + .onAppear { + avatarImage = AvatarRepository.shared.loadAvatar(publicKey: publicKey) + startAnimations() + } + .task { await autoTriggerBiometric() } + .confirmationDialog("Switch Account", isPresented: $showAccountPicker, titleVisibility: .visible) { + ForEach(AccountManager.shared.allAccounts, id: \.publicKey) { account in + let isActive = account.publicKey == publicKey + Button(accountLabel(for: account)) { + if !isActive { + switchAccount(to: account.publicKey) + } + } + .disabled(isActive) + } + Button("Cancel", role: .cancel) {} + } } } @@ -120,28 +170,7 @@ private extension UnlockView { var passwordField: some View { VStack(alignment: .leading, spacing: 8) { GlassCard(cornerRadius: 14, fillOpacity: 0.08) { - HStack(spacing: 12) { - Group { - if showPassword { - TextField("Password", text: $password) - } else { - SecureField("Password", text: $password) - } - } - .font(.system(size: 16)) - .foregroundStyle(.white) - .textContentType(.password) - .submitLabel(.done) - .onSubmit { unlock() } - - Image(systemName: showPassword ? "eye.slash" : "eye") - .font(.system(size: 18)) - .foregroundStyle(Color.white.opacity(0.5)) - .contentShape(Rectangle()) - .onTapGesture { showPassword.toggle() } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) + UnlockPasswordField(text: $password, onSubmit: { unlock() }) } .overlay { RoundedRectangle(cornerRadius: 14) @@ -167,14 +196,12 @@ private extension UnlockView { private extension UnlockView { var unlockButton: some View { Button(action: unlock) { - HStack(spacing: 10) { + Group { if isUnlocking { ProgressView() .tint(.white) .scaleEffect(0.9) } else { - Image(systemName: "lock.open.fill") - .font(.system(size: 16)) Text("Enter") .font(.system(size: 16, weight: .semibold)) } @@ -183,8 +210,31 @@ private extension UnlockView { .frame(maxWidth: .infinity) .frame(height: 56) } - .buttonStyle(RosettaPrimaryButtonStyle(isEnabled: !password.isEmpty && !isUnlocking)) - .disabled(password.isEmpty || isUnlocking) + .buttonStyle(RosettaPrimaryButtonStyle()) + .shine() + .disabled(isUnlocking) + } + + /// Desktop parity: fingerprint/biometric icon instead of lock. + private var biometricIconName: String { + BiometricAuthManager.shared.biometricIconName + } + + /// Face ID / Touch ID button — allows user to re-trigger biometric unlock. + var biometricButton: some View { + Button { + triggerBiometricUnlock() + } label: { + HStack(spacing: 8) { + Image(systemName: biometricIconName) + .font(.system(size: 20)) + Text("Unlock with \(BiometricAuthManager.shared.biometricName)") + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(Color.white.opacity(0.8)) + } + .buttonStyle(.plain) + .disabled(isUnlocking) } } @@ -235,13 +285,30 @@ private extension UnlockView { private extension UnlockView { func unlock() { - guard !password.isEmpty, !isUnlocking else { return } + guard !isUnlocking else { return } + guard !password.isEmpty else { + withAnimation(.easeInOut(duration: 0.2)) { + errorMessage = "Please enter your password." + } + return + } isUnlocking = true errorMessage = nil + let enteredPassword = password + let accountKey = publicKey + Task { do { - try await SessionManager.shared.startSession(password: password) + try await SessionManager.shared.startSession(password: enteredPassword) + + // If biometric is enabled, update the stored password + // (in case user changed password and re-entered it manually) + let biometric = BiometricAuthManager.shared + if biometric.isBiometricEnabled(forAccount: accountKey) { + try? biometric.savePassword(enteredPassword, forAccount: accountKey) + } + onUnlocked() } catch { withAnimation(.easeInOut(duration: 0.2)) { @@ -252,6 +319,34 @@ private extension UnlockView { } } + /// Label for account in the picker dialog. + func accountLabel(for account: Account) -> String { + let name = account.displayName ?? "" + if !name.isEmpty { return name } + guard account.publicKey.count >= 7 else { return account.publicKey } + return String(account.publicKey.prefix(7)) + } + + /// Switches active account — resets password/error/biometric state, reloads avatar. + /// Desktop parity: `selectAccountToLoginDice()` in `Lockscreen.tsx`. + func switchAccount(to newPublicKey: String) { + AccountManager.shared.setActiveAccount(publicKey: newPublicKey) + + // Reset input state + password = "" + errorMessage = nil + isUnlocking = false + biometricTriggered = false + + // Reload avatar for the new account + avatarImage = AvatarRepository.shared.loadAvatar(publicKey: newPublicKey) + + // Auto-trigger biometric for the new account if available + Task { + await autoTriggerBiometric() + } + } + func startAnimations() { withAnimation(.easeOut(duration: 0.3)) { showAvatar = true } withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true } @@ -260,4 +355,183 @@ private extension UnlockView { withAnimation(.easeOut(duration: 0.3).delay(0.20)) { showButton = true } withAnimation(.easeOut(duration: 0.3).delay(0.24)) { showFooter = true } } + + /// Auto-triggers biometric unlock after animations complete. + func autoTriggerBiometric() async { + guard canUseBiometric, !biometricTriggered else { return } + biometricTriggered = true + + // Wait for staggered animations to finish before showing Face ID prompt + try? await Task.sleep(nanoseconds: 600_000_000) + + guard !isUnlocking else { return } + await performBiometricUnlock() + } + + /// Triggered by user tapping the biometric button. + func triggerBiometricUnlock() { + guard !isUnlocking else { return } + Task { + await performBiometricUnlock() + } + } + + /// Performs the biometric unlock flow: authenticate → load password → start session. + func performBiometricUnlock() async { + guard !isUnlocking else { return } + isUnlocking = true + errorMessage = nil + + do { + let storedPassword = try await BiometricAuthManager.shared.unlockWithBiometric( + forAccount: publicKey + ) + try await SessionManager.shared.startSession(password: storedPassword) + onUnlocked() + } catch let error as BiometricError { + isUnlocking = false + // User cancelled — silently return to password input + if case .cancelled = error { return } + withAnimation(.easeInOut(duration: 0.2)) { + errorMessage = error.localizedDescription + } + } catch { + // SessionManager.startSession failed — stored password might be wrong + withAnimation(.easeInOut(duration: 0.2)) { + errorMessage = "Wrong password. Please try again." + } + isUnlocking = false + } + } +} + +// MARK: - UIKit Password Field + +/// UITextField subclass with locked intrinsicContentSize. +/// Prevents layout propagation to SwiftUI when isSecureTextEntry toggles. +private final class UnlockTextField: UITextField { + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: 50) + } + + override func textRect(forBounds bounds: CGRect) -> CGRect { + bounds.inset(by: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 46)) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + textRect(forBounds: bounds) + } + + override func rightViewRect(forBounds bounds: CGRect) -> CGRect { + CGRect(x: bounds.width - 16 - 30, y: (bounds.height - 30) / 2, width: 30, height: 30) + } + + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + textRect(forBounds: bounds) + } +} + +/// Wraps UIKit UITextField with built-in eye toggle as rightView. +/// Toggle happens entirely in UIKit — no SwiftUI state, no body re-evaluation. +private struct UnlockPasswordField: UIViewRepresentable { + @Binding var text: String + var onSubmit: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + func makeUIView(context: Context) -> UnlockTextField { + let tf = UnlockTextField() + context.coordinator.textField = tf + + tf.isSecureTextEntry = true + tf.font = .systemFont(ofSize: 16) + tf.textColor = .white + tf.tintColor = UIColor(RosettaColors.primaryBlue) + tf.autocapitalizationType = .none + tf.autocorrectionType = .no + tf.spellCheckingType = .no + tf.textContentType = .password + tf.returnKeyType = .done + tf.backgroundColor = .clear + tf.setContentHuggingPriority(.required, for: .vertical) + tf.setContentCompressionResistancePriority(.required, for: .vertical) + + tf.delegate = context.coordinator + tf.addTarget( + context.coordinator, + action: #selector(Coordinator.textChanged(_:)), + for: .editingChanged + ) + tf.attributedPlaceholder = NSAttributedString( + string: "Password", + attributes: [.foregroundColor: UIColor.white.withAlphaComponent(0.3)] + ) + + // Eye toggle button — entirely UIKit, light weight for clean Mantine-like look + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin) + let eyeButton = UIButton(type: .system) + eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal) + eyeButton.tintColor = UIColor.white.withAlphaComponent(0.4) + eyeButton.addTarget( + context.coordinator, + action: #selector(Coordinator.toggleSecure), + for: .touchUpInside + ) + eyeButton.frame = CGRect(x: 0, y: 0, width: 30, height: 30) + eyeButton.accessibilityLabel = "Show password" + tf.rightView = eyeButton + tf.rightViewMode = .always + + return tf + } + + func updateUIView(_ tf: UnlockTextField, context: Context) { + context.coordinator.parent = self + if tf.text != text { tf.text = text } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: UnlockTextField, + context: Context + ) -> CGSize? { + CGSize(width: proposal.width ?? 200, height: 50) + } + + final class Coordinator: NSObject, UITextFieldDelegate { + var parent: UnlockPasswordField + weak var textField: UnlockTextField? + + init(parent: UnlockPasswordField) { self.parent = parent } + + @objc func textChanged(_ tf: UITextField) { + parent.text = tf.text ?? "" + } + + @objc func toggleSecure() { + guard let tf = textField else { return } + UIView.performWithoutAnimation { + let existingText = tf.text + tf.isSecureTextEntry.toggle() + tf.text = "" + tf.text = existingText + + let imageName = tf.isSecureTextEntry ? "eye" : "eye.slash" + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin) + let button = tf.rightView as? UIButton + button?.setImage( + UIImage(systemName: imageName, withConfiguration: config), + for: .normal + ) + button?.accessibilityLabel = tf.isSecureTextEntry + ? "Show password" + : "Hide password" + } + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + parent.onSubmit() + return true + } + } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 0f024fd..5211fa8 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -10,6 +10,34 @@ private struct ComposerHeightKey: PreferenceKey { } } +/// Reads keyboardPadding in its own observation scope — +/// parent body is NOT re-evaluated on padding changes. +private struct KeyboardSpacer: View { + @ObservedObject private var keyboard = KeyboardTracker.shared + let composerHeight: CGFloat + + var body: some View { + Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4) + } +} + +/// Applies keyboard bottom padding in an isolated observation scope. +/// Parent view is NOT marked dirty when keyboardPadding changes. +private struct KeyboardPaddedView: View { + @ObservedObject private var keyboard = KeyboardTracker.shared + let extraPadding: CGFloat + let content: Content + + init(extraPadding: CGFloat = 0, @ViewBuilder content: () -> Content) { + self.extraPadding = extraPadding + self.content = content() + } + + var body: some View { + content.offset(y: -(keyboard.keyboardPadding + extraPadding)) + } +} + struct ChatDetailView: View { let route: ChatRoute var onPresentedChange: ((Bool) -> Void)? = nil @@ -30,8 +58,10 @@ struct ChatDetailView: View { @State private var isInputFocused = false @State private var isAtBottom = true @State private var composerHeight: CGFloat = 56 - @StateObject private var keyboard = KeyboardTracker() @State private var shouldScrollOnNextMessage = false + /// Captured on chat open — ID of the first unread incoming message (for separator). + @State private var firstUnreadMessageId: String? + @State private var isSendingAvatar = false private var currentPublicKey: String { SessionManager.shared.currentPublicKey @@ -66,6 +96,8 @@ struct ChatDetailView: View { private var subtitleText: String { if route.isSavedMessages { return "" } + // Desktop parity: system accounts show "official account" instead of online/offline + if route.isSystemAccount { return "official account" } if isTyping { return "typing..." } if let dialog, dialog.isOnline { return "online" } return "offline" @@ -115,20 +147,38 @@ struct ChatDetailView: View { } .overlay { chatEdgeGradients } .overlay(alignment: .bottom) { - composer - .background( - GeometryReader { geo in - Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height) - } - ) - .padding(.bottom, keyboard.keyboardPadding) + if !route.isSystemAccount { + KeyboardPaddedView { + composer + .background( + GeometryReader { geo in + Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height) + } + ) + } + } } - .onPreferenceChange(ComposerHeightKey.self) { composerHeight = $0 } - .ignoresSafeArea(.keyboard) + .onPreferenceChange(ComposerHeightKey.self) { newHeight in + composerHeight = newHeight + } + .modifier(IgnoreKeyboardSafeAreaLegacy()) .background { - ZStack { + ZStack(alignment: .bottom) { RosettaColors.Adaptive.background tiledChatBackground + // Telegram-style: dark gradient at screen bottom (home indicator area). + // In background (not overlay) so it never moves with keyboard. + if #unavailable(iOS 26) { + LinearGradient( + colors: [ + Color.black.opacity(0.0), + Color.black.opacity(0.55) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 34) + } } .ignoresSafeArea() } @@ -140,6 +190,12 @@ struct ChatDetailView: View { .toolbar(.hidden, for: .tabBar) .task { isViewActive = true + // Capture first unread incoming message BEFORE marking as read. + if firstUnreadMessageId == nil { + firstUnreadMessageId = messages.first(where: { + !$0.isRead && $0.fromPublicKey != currentPublicKey + })?.id + } // Desktop parity: restore draft text from DraftManager. let draft = DraftManager.shared.getDraft(for: route.publicKey) if !draft.isEmpty { @@ -161,23 +217,29 @@ struct ChatDetailView: View { guard isViewActive else { return } activateDialog() markDialogAsRead() - // Subscribe to opponent's online status (Android parity) — only after settled - SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey) - // Desktop parity: force-refresh user info (incl. online status) on chat open. - // PacketSearch (0x03) returns current online state, supplementing 0x05 subscription. - if !route.isSavedMessages { - SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey) + // Desktop parity: skip online subscription and user info fetch for system accounts + if !route.isSystemAccount { + // Subscribe to opponent's online status (Android parity) — only after settled + SessionManager.shared.subscribeToOnlineStatus(publicKey: route.publicKey) + // Desktop parity: force-refresh user info (incl. online status) on chat open. + // PacketSearch (0x03) returns current online state, supplementing 0x05 subscription. + if !route.isSavedMessages { + SessionManager.shared.forceRefreshUserInfo(publicKey: route.publicKey) + } } } .onDisappear { isViewActive = false + firstUnreadMessageId = nil MessageRepository.shared.setDialogActive(route.publicKey, isActive: false) // Desktop parity: save draft text on chat close. DraftManager.shared.saveDraft(for: route.publicKey, text: messageText) } } - var body: some View { content } + var body: some View { + content + } } private extension ChatDetailView { @@ -244,7 +306,8 @@ private extension ChatDetailView { colorIndex: avatarColorIndex, size: 35, isOnline: false, - isSavedMessages: route.isSavedMessages + isSavedMessages: route.isSavedMessages, + image: opponentAvatar ) .frame(width: 36, height: 36) .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } @@ -300,7 +363,8 @@ private extension ChatDetailView { colorIndex: avatarColorIndex, size: 38, isOnline: false, - isSavedMessages: route.isSavedMessages + isSavedMessages: route.isSavedMessages, + image: opponentAvatar ) .frame(width: 44, height: 44) .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } @@ -335,6 +399,11 @@ private extension ChatDetailView { RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey) } + /// Avatar image for the opponent. System accounts return a bundled static image. + var opponentAvatar: UIImage? { + AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) + } + var incomingBubbleFill: Color { RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E)) } @@ -415,7 +484,8 @@ private extension ChatDetailView { colorIndex: avatarColorIndex, size: 80, isOnline: dialog?.isOnline ?? false, - isSavedMessages: route.isSavedMessages + isSavedMessages: route.isSavedMessages, + image: opponentAvatar ) VStack(spacing: 4) { @@ -456,8 +526,8 @@ private extension ChatDetailView { // Spacer for composer + keyboard — OUTSIDE LazyVStack so padding // changes only shift the LazyVStack as a whole block (cheap), // instead of re-laying out every cell inside it (expensive). - Color.clear - .frame(height: composerHeight + keyboard.keyboardPadding + 4) + // Isolated in KeyboardSpacer to avoid marking parent dirty. + KeyboardSpacer(composerHeight: composerHeight) // LazyVStack: only visible cells are loaded. Internal layout // is unaffected by the spacer above changing height. @@ -479,6 +549,13 @@ private extension ChatDetailView { ) .scaleEffect(x: 1, y: -1) // flip each row back to normal .id(message.id) + + // Unread Messages separator (Telegram style). + // In inverted scroll, "above" visually = after in code. + if message.id == firstUnreadMessageId { + unreadSeparator + .scaleEffect(x: 1, y: -1) + } } } } @@ -512,7 +589,9 @@ private extension ChatDetailView { scroll .scrollIndicators(.hidden) .overlay(alignment: .bottom) { - scrollToBottomButton(proxy: proxy) + KeyboardPaddedView(extraPadding: composerHeight + 4) { + scrollToBottomButton(proxy: proxy) + } } } } @@ -542,7 +621,6 @@ private extension ChatDetailView { .transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity)) } } - .padding(.bottom, composerHeight + keyboard.keyboardPadding + 4) .padding(.trailing, composerTrailingPadding) .allowsHitTesting(!isAtBottom) } @@ -555,7 +633,7 @@ private extension ChatDetailView { // Telegram-style compact bubble: inline time+status at bottom-trailing. // Right padding reserves space for the timestamp overlay (64pt outgoing, 48pt incoming). - Text(messageText) + Text(parsedMarkdown(messageText)) .font(.system(size: 17, weight: .regular)) .tracking(-0.43) .foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text) @@ -600,6 +678,34 @@ private extension ChatDetailView { .padding(.bottom, 0) } + // MARK: - Markdown Parsing + + /// Parses inline markdown (`**bold**`) from runtime strings. + /// Falls back to plain `AttributedString` if parsing fails. + private func parsedMarkdown(_ text: String) -> AttributedString { + if let parsed = try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + return parsed + } + return AttributedString(text) + } + + // MARK: - Unread Separator + + private var unreadSeparator: some View { + Text("Unread Messages") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.white.opacity(0.7)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.white.opacity(0.08)) + .padding(.horizontal, -10) // compensate scroll content padding + .padding(.top, 6) + .padding(.bottom, 2) + } + // MARK: - Composer var composer: some View { @@ -613,8 +719,15 @@ private extension ChatDetailView { } HStack(alignment: .bottom, spacing: 0) { - Button { - // Placeholder for attachment picker + // Desktop parity: paperclip opens attachment menu with camera option. + // Camera sends current user's avatar to this chat. + Menu { + Button { + sendAvatarToChat() + } label: { + Label("Send Avatar", systemImage: "camera.fill") + } + .disabled(isSendingAvatar) } label: { TelegramVectorIcon( pathData: TelegramIconPath.paperclip, @@ -633,7 +746,7 @@ private extension ChatDetailView { text: $messageText, isFocused: $isInputFocused, onKeyboardHeightChange: { height in - keyboard.updateFromKVO(keyboardHeight: height) + KeyboardTracker.shared.updateFromKVO(keyboardHeight: height) }, onUserTextInsertion: handleComposerUserTyping, textColor: UIColor(RosettaColors.Adaptive.text), @@ -724,26 +837,6 @@ private extension ChatDetailView { .animation(composerAnimation, value: canSend) .animation(composerAnimation, value: shouldShowSendButton) } - .background { - if #available(iOS 26, *) { - Color.clear - } else { - // Telegram-style: dark gradient below composer → home indicator - VStack(spacing: 0) { - Spacer() - LinearGradient( - colors: [ - Color.black.opacity(0.0), - Color.black.opacity(0.55) - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 34) - } - .ignoresSafeArea(edges: .bottom) - } - } } // MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom) @@ -957,7 +1050,10 @@ private extension ChatDetailView { func markDialogAsRead() { DialogRepository.shared.markAsRead(opponentKey: route.publicKey) MessageRepository.shared.markIncomingAsRead(opponentKey: route.publicKey, myPublicKey: currentPublicKey) - SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey) + // Desktop parity: don't send read receipts for system accounts + if !route.isSystemAccount { + SessionManager.shared.sendReadReceipt(toPublicKey: route.publicKey) + } } /// Remove all delivered push notifications from this specific sender. @@ -1001,6 +1097,26 @@ private extension ChatDetailView { } } + /// Desktop parity: onClickCamera() — sends current user's avatar to this chat. + func sendAvatarToChat() { + guard !isSendingAvatar else { return } + isSendingAvatar = true + sendError = nil + + Task { @MainActor in + do { + try await SessionManager.shared.sendAvatar( + toPublicKey: route.publicKey, + opponentTitle: route.title, + opponentUsername: route.username + ) + } catch { + sendError = "Failed to send avatar" + } + isSendingAvatar = false + } + } + func handleComposerUserTyping() { SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey) } @@ -1286,6 +1402,18 @@ private enum TelegramIconPath { static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"# } +/// iOS < 26: ignore keyboard safe area (manual KeyboardTracker handles offset). +/// iOS 26+: let SwiftUI handle keyboard natively — no manual tracking. +private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + } else { + content.ignoresSafeArea(.keyboard) + } + } +} + #Preview { NavigationStack { diff --git a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift index a7f287e..9df5860 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift @@ -182,6 +182,10 @@ private extension ChatListSearchContent { name: user.title, publicKey: user.publicKey ) let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey) + let effectiveVerified = Self.effectiveVerifiedLevel( + verified: user.verified, title: user.title, + username: user.username, publicKey: user.publicKey + ) return Button { onOpenDialog(ChatRoute(recent: user)) @@ -193,16 +197,28 @@ private extension ChatListSearchContent { ) VStack(alignment: .leading, spacing: 1) { - Text(isSelf ? "Saved Messages" : ( - user.title.isEmpty - ? String(user.publicKey.prefix(16)) + "..." - : user.title - )) - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(1) - if !user.lastSeenText.isEmpty { - Text(user.lastSeenText) + HStack(spacing: 4) { + Text(isSelf ? "Saved Messages" : ( + user.title.isEmpty + ? String(user.publicKey.prefix(16)) + "..." + : user.title + )) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + if !isSelf && effectiveVerified > 0 { + VerifiedBadge( + verified: effectiveVerified, + size: 14 + ) + } + } + // Desktop parity: search subtitle shows @username, not online/offline. + if !isSelf { + Text(user.username.isEmpty + ? "@\(String(user.publicKey.prefix(10)))..." + : "@\(user.username)" + ) .font(.system(size: 13)) .foregroundStyle(RosettaColors.Adaptive.textSecondary) .lineLimit(1) @@ -216,6 +232,17 @@ private extension ChatListSearchContent { } .buttonStyle(.plain) } + + /// Desktop parity: compute effective verified level — server value + client heuristic. + private static func effectiveVerifiedLevel( + verified: Int, title: String, username: String, publicKey: String + ) -> Int { + if verified > 0 { return verified } + if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 } + if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 } + if SystemAccounts.isSystemAccount(publicKey) { return 1 } + return 0 + } } // MARK: - Server User Row diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index d2fa8e9..7d41ea0 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -355,21 +355,58 @@ private struct ChatListToolbarBackgroundModifier: ViewModifier { // MARK: - Toolbar Title (observation-isolated) -/// Reads `ProtocolManager.shared.connectionState` in its own observation scope. -/// Connection state changes during handshake (4+ rapid transitions) are absorbed here, +/// Reads `ProtocolManager.shared.connectionState` and `SessionManager.shared.syncBatchInProgress` +/// in its own observation scope. State changes are absorbed here, /// not cascaded to the parent ChatListView / NavigationStack. private struct ToolbarTitleView: View { var body: some View { let state = ProtocolManager.shared.connectionState - let title: String = switch state { - case .authenticated: "Chats" - default: "Connecting..." + let isSyncing = SessionManager.shared.syncBatchInProgress + + if state == .authenticated && isSyncing { + UpdatingDotsView() + } else { + let title: String = switch state { + case .authenticated: "Chats" + default: "Connecting..." + } + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .contentTransition(.numericText()) + .animation(.easeInOut(duration: 0.25), value: state) + } + } +} + +/// Desktop parity: "Updating..." with bouncing dots animation during sync. +private struct UpdatingDotsView: View { + @State private var activeDot = 0 + private let dotCount = 3 + private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect() + + var body: some View { + HStack(spacing: 1) { + Text("Updating") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + HStack(spacing: 2) { + ForEach(0.. 0 { + VerifiedBadge( + verified: effectiveVerified, + size: 14 + ) + } + } + // Desktop parity: search subtitle shows @username, not online/offline. + if !isSelf { + Text(user.username.isEmpty + ? "@\(String(user.publicKey.prefix(10)))..." + : "@\(user.username)" + ) .font(.system(size: 13)) .foregroundStyle(RosettaColors.Adaptive.textSecondary) .lineLimit(1) @@ -290,6 +305,17 @@ private struct RecentSection: View { } .buttonStyle(.plain) } + + /// Desktop parity: compute effective verified level — server value + client heuristic. + private static func effectiveVerifiedLevel( + verified: Int, title: String, username: String, publicKey: String + ) -> Int { + if verified > 0 { return verified } + if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 } + if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 } + if SystemAccounts.isSystemAccount(publicKey) { return 1 } + return 0 + } } // MARK: - Search Results Content diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index ea9274f..410717b 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -3,10 +3,19 @@ import SwiftUI /// Main container view with tab-based navigation. struct MainTabView: View { var onLogout: (() -> Void)? + /// Always start on Chats tab after login / account switch. + /// Using @State (not @SceneStorage) ensures the tab resets to .chats + /// when MainTabView is recreated after an account switch or unlock. @State private var selectedTab: RosettaTab = .chats @State private var isChatSearchActive = false @State private var isChatListDetailPresented = false @State private var isSettingsEditPresented = false + @State private var isSettingsDetailPresented = false + + // Add Account — presented as fullScreenCover so Settings stays alive. + // Using optional AuthScreen as the item ensures the correct screen is + // passed directly to the content closure (no stale capture). + @State private var addAccountScreen: AuthScreen? /// All tabs are pre-activated so that switching only changes the offset, /// not the view structure. Creating a NavigationStack mid-animation causes /// "Update NavigationRequestObserver tried to update multiple times per frame" → freeze. @@ -14,6 +23,13 @@ struct MainTabView: View { /// When non-nil, the tab bar is being dragged and the pager follows interactively. @State private var dragFractionalIndex: CGFloat? + /// Local handler for Add Account — triggers fullScreenCover instead of app-level navigation. + private var handleAddAccount: (AuthScreen) -> Void { + { screen in + addAccountScreen = screen + } + } + var body: some View { Group { if #available(iOS 26.0, *) { @@ -22,6 +38,20 @@ struct MainTabView: View { legacyTabView } } + .fullScreenCover(item: $addAccountScreen) { screen in + AuthCoordinator( + onAuthComplete: { + addAccountScreen = nil + // New account created — end session and go to unlock for the new account + SessionManager.shared.endSession() + onLogout?() + }, + onBackToUnlock: { + addAccountScreen = nil + }, + initialScreen: screen + ) + } } // MARK: - iOS 26+ (native TabView with liquid glass tab bar) @@ -45,7 +75,7 @@ struct MainTabView: View { .tag(RosettaTab.chats) .badge(chatUnreadCount) - SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented) + SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) .tabItem { Label(RosettaTab.settings.label, systemImage: RosettaTab.settings.icon) } @@ -66,7 +96,7 @@ struct MainTabView: View { } .ignoresSafeArea() - if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented { + if !isChatSearchActive && !isAnyChatDetailPresented && !isSettingsEditPresented && !isSettingsDetailPresented { RosettaTabBar( selectedTab: selectedTab, onTabSelected: { tab in @@ -93,6 +123,7 @@ struct MainTabView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } + .ignoresSafeArea(.keyboard) .onChange(of: isChatSearchActive) { _, isActive in if isActive { dragFractionalIndex = nil @@ -143,7 +174,7 @@ struct MainTabView: View { case .calls: CallsView() case .settings: - SettingsView(onLogout: onLogout, isEditingProfile: $isSettingsEditPresented) + SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) } } else { RosettaColors.Adaptive.background diff --git a/Rosetta/Features/Settings/BackupView.swift b/Rosetta/Features/Settings/BackupView.swift new file mode 100644 index 0000000..96c453d --- /dev/null +++ b/Rosetta/Features/Settings/BackupView.swift @@ -0,0 +1,333 @@ +import SwiftUI + +/// Backup screen — desktop/Android parity. +/// User enters password → decrypts seed phrase → displays 12-word grid. +struct BackupView: View { + @Environment(\.dismiss) private var dismiss + + @State private var password = "" + @State private var seedWords: [String] = [] + @State private var isVerifying = false + @State private var errorMessage: String? + @State private var isPasswordVisible = false + @State private var copied = false + @FocusState private var isPasswordFocused: Bool + + private var isUnlocked: Bool { !seedWords.isEmpty } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + if isUnlocked { + seedPhraseContent + .transition(.opacity) + } else { + passwordContent + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.3), value: isUnlocked) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 100) + } + .background(RosettaColors.Adaptive.background) + .scrollContentBackground(.hidden) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .enableSwipeBack() + .toolbar { toolbarContent } + .toolbarBackground(.hidden, for: .navigationBar) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .glassCircle() + } + } + + // MARK: - Password Content + + private var passwordContent: some View { + VStack(spacing: 24) { + LottieView( + animationName: "lock", + animationSpeed: 1.0 + ) + .frame(width: 120, height: 120) + .padding(.top, 20) + + VStack(spacing: 8) { + Text("Enter your password") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Text("To view your seed phrase, please enter your account password.") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + + passwordField + + if let errorMessage { + Text(errorMessage) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.error) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + + verifyButton + } + } + + private var passwordField: some View { + SettingsCard { + HStack(spacing: 12) { + Group { + if isPasswordVisible { + TextField("Password", text: $password) + } else { + SecureField("Password", text: $password) + } + } + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + .focused($isPasswordFocused) + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.done) + .onSubmit { verifyPassword() } + + Button { + isPasswordVisible.toggle() + } label: { + Image(systemName: isPasswordVisible ? "eye.slash" : "eye") + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .frame(height: 52) + } + .task { + // Delay keyboard until push animation finishes + try? await Task.sleep(nanoseconds: 450_000_000) + isPasswordFocused = true + } + } + + private var verifyButton: some View { + Button { + verifyPassword() + } label: { + Group { + if isVerifying { + ProgressView() + .tint(.white) + } else { + Text("Verify") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.white) + } + } + .frame(maxWidth: .infinity) + .frame(height: 48) + } + .buttonStyle(RosettaPrimaryButtonStyle()) + .disabled(password.isEmpty || isVerifying) + .opacity(password.isEmpty ? 0.5 : 1.0) + } + + // MARK: - Seed Phrase Content + + private var seedPhraseContent: some View { + VStack(spacing: 20) { + warningBanner + + seedPhraseGrid + + copyButton + + Text("Never share your seed phrase with anyone. Anyone with your seed phrase can access your account.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + } + + private var warningBanner: some View { + SettingsCard { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 20)) + .foregroundStyle(.orange) + + VStack(alignment: .leading, spacing: 2) { + Text("Keep it secret") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Text("Do not share your seed phrase with anyone.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + + Spacer() + } + .padding(16) + } + } + + private var seedPhraseGrid: some View { + let leftColumn = Array(seedWords.prefix(6)) + let rightColumn = Array(seedWords.dropFirst(6)) + + return HStack(alignment: .top, spacing: 12) { + seedColumn(words: leftColumn, startIndex: 1) + seedColumn(words: rightColumn, startIndex: 7) + } + } + + private func seedColumn(words: [String], startIndex: Int) -> some View { + VStack(spacing: 10) { + ForEach(Array(words.enumerated()), id: \.offset) { offset, word in + let globalIndex = startIndex + offset - 1 + seedWordCard( + number: startIndex + offset, + word: word, + colorIndex: globalIndex + ) + } + } + } + + private func seedWordCard(number: Int, word: String, colorIndex: Int) -> some View { + let color = RosettaColors.seedWordColors[colorIndex % RosettaColors.seedWordColors.count] + + return HStack(spacing: 8) { + Text("\(number).") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(width: 28, alignment: .trailing) + + Text(word) + .font(.system(size: 17, weight: .semibold, design: .monospaced)) + .foregroundStyle(color) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .modifier(BackupSeedCardStyle(color: color)) + } + + private var copyButton: some View { + Button { + UIPasteboard.general.string = seedWords.joined(separator: " ") + copied = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + copied = false + } + } label: { + HStack(spacing: 8) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 15)) + Text(copied ? "Copied" : "Copy Seed Phrase") + .font(.system(size: 17, weight: .medium)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + } + .buttonStyle(RosettaPrimaryButtonStyle()) + } + + // MARK: - Password Verification + + private func verifyPassword() { + guard !password.isEmpty, !isVerifying else { return } + isVerifying = true + errorMessage = nil + + // Capture for sendable detached task + let enteredPassword = password + let account = AccountManager.shared.currentAccount + let crypto = CryptoManager.shared + + Task.detached(priority: .userInitiated) { + do { + guard let account else { + await MainActor.run { + errorMessage = "No account found" + isVerifying = false + } + return + } + let decrypted = try crypto.decryptWithPassword( + account.seedPhraseEncrypted, + password: enteredPassword + ) + guard let phrase = String(data: decrypted, encoding: .utf8) else { + throw CryptoError.decryptionFailed + } + let words = phrase.components(separatedBy: " ").filter { !$0.isEmpty } + guard words.count == 12 else { + throw CryptoError.decryptionFailed + } + await MainActor.run { + seedWords = words + isVerifying = false + } + } catch { + await MainActor.run { + errorMessage = "Wrong password" + isVerifying = false + } + } + } + } +} + +// MARK: - Seed Card Style + +private struct BackupSeedCardStyle: ViewModifier { + let color: Color + + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12)) + } else { + content + .background { + RoundedRectangle(cornerRadius: 12) + .fill(color.opacity(0.12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(color.opacity(0.18), lineWidth: 0.5) + } + } + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } +} diff --git a/Rosetta/Features/Settings/ProfileEditView.swift b/Rosetta/Features/Settings/ProfileEditView.swift index 6a71b0c..3933ac1 100644 --- a/Rosetta/Features/Settings/ProfileEditView.swift +++ b/Rosetta/Features/Settings/ProfileEditView.swift @@ -4,15 +4,19 @@ import SwiftUI /// Embedded profile editing content (no NavigationStack — lives inside SettingsView's). /// Avatar + photo picker, name fields with validation. struct ProfileEditView: View { + var onAddAccount: ((AuthScreen) -> Void)? @Binding var displayName: String @Binding var username: String let publicKey: String @Binding var displayNameError: String? @Binding var usernameError: String? + /// Photo selected but not yet saved — only committed when Done is pressed. + @Binding var pendingPhoto: UIImage? @State private var selectedPhotoItem: PhotosPickerItem? @State private var selectedPhoto: UIImage? + @State private var showAddAccountSheet = false private var initials: String { RosettaColors.initials(name: displayName, publicKey: publicKey) @@ -36,8 +40,13 @@ struct ProfileEditView: View { addAccountSection } .padding(.horizontal, 16) - .padding(.top, 24) + .padding(.top, 8) .padding(.bottom, 100) + .task { + if selectedPhoto == nil { + selectedPhoto = AvatarRepository.shared.loadAvatar(publicKey: publicKey) + } + } } } @@ -46,20 +55,13 @@ struct ProfileEditView: View { private extension ProfileEditView { var avatarSection: some View { VStack(spacing: 12) { - if let selectedPhoto { - Image(uiImage: selectedPhoto) - .resizable() - .scaledToFill() - .frame(width: 80, height: 80) - .clipShape(Circle()) - } else { - AvatarView( - initials: initials, - colorIndex: avatarColorIndex, - size: 80, - isSavedMessages: false - ) - } + AvatarView( + initials: initials, + colorIndex: avatarColorIndex, + size: 80, + isSavedMessages: false, + image: selectedPhoto + ) PhotosPicker(selection: $selectedPhotoItem, matching: .images) { Text("Set New Photo") @@ -72,6 +74,8 @@ private extension ProfileEditView { if let data = try? await item?.loadTransferable(type: Data.self), let image = UIImage(data: data) { selectedPhoto = image + // Preview only — actual save happens when Done is pressed + pendingPhoto = image } } } @@ -171,7 +175,9 @@ private extension ProfileEditView { private extension ProfileEditView { var addAccountSection: some View { GlassCard(cornerRadius: 26, fillOpacity: 0.08) { - Button {} label: { + Button { + showAddAccountSheet = true + } label: { HStack { Spacer() Text("Add Another Account") @@ -180,9 +186,31 @@ private extension ProfileEditView { Spacer() } .frame(height: 52) + .contentShape(Rectangle()) } .buttonStyle(.plain) } + .confirmationDialog( + "Create account", + isPresented: $showAddAccountSheet, + titleVisibility: .visible + ) { + Button("Create New Account") { + // Let dialog dismiss animation complete before heavy state changes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + onAddAccount?(.seedPhrase) + } + } + Button("Import Existing Account") { + // Let dialog dismiss animation complete before heavy state changes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + onAddAccount?(.importSeed) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You may create a new account or import an existing one.") + } } func helperText(_ text: String) -> some View { @@ -204,7 +232,8 @@ private extension ProfileEditView { username: .constant("GaidarTheDev"), publicKey: "028d1c9d0000000000000000000000000000000000000000000000000000008e03ec", displayNameError: .constant(nil), - usernameError: .constant(nil) + usernameError: .constant(nil), + pendingPhoto: .constant(nil) ) } .background(RosettaColors.Adaptive.background) diff --git a/Rosetta/Features/Settings/SafetyView.swift b/Rosetta/Features/Settings/SafetyView.swift new file mode 100644 index 0000000..118f49c --- /dev/null +++ b/Rosetta/Features/Settings/SafetyView.swift @@ -0,0 +1,247 @@ +import SwiftUI + +/// Safety screen — Android parity. +/// Shows public/private keys with copy, backup navigation, and delete account. +struct SafetyView: View { + var onLogout: (() -> Void)? + + @Environment(\.dismiss) private var dismiss + @State private var copiedPublicKey = false + @State private var copiedPrivateKey = false + @State private var showDeleteConfirmation = false + + private var publicKey: String { + SessionManager.shared.currentPublicKey + } + + private var privateKeyHash: String { + SessionManager.shared.privateKeyHash ?? "" + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + keysSection + actionsSection + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 100) + } + .background(RosettaColors.Adaptive.background) + .scrollContentBackground(.hidden) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .enableSwipeBack() + .toolbar { toolbarContent } + .toolbarBackground(.hidden, for: .navigationBar) + .alert("Delete Account", isPresented: $showDeleteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Delete Account", role: .destructive) { + let publicKey = SessionManager.shared.currentPublicKey + BiometricAuthManager.shared.clearAll(forAccount: publicKey) + AvatarRepository.shared.removeAvatar(publicKey: publicKey) + // Clear persisted chat files before session ends + DialogRepository.shared.reset(clearPersisted: true) + MessageRepository.shared.reset(clearPersisted: true) + // Clear per-account UserDefaults entries + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)") + defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)") + defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)") + SessionManager.shared.endSession() + try? AccountManager.shared.deleteAccount() + onLogout?() + } + } message: { + Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.") + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .glassCircle() + } + } + + // MARK: - Keys Section + + private var keysSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("KEYS") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .padding(.horizontal, 4) + + SettingsCard { + VStack(spacing: 0) { + copyRow( + label: "Public Key", + value: publicKey, + isCopied: copiedPublicKey, + position: .top + ) { + UIPasteboard.general.string = publicKey + copiedPublicKey = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + copiedPublicKey = false + } + } + + Divider() + .background(RosettaColors.Adaptive.divider) + .padding(.horizontal, 16) + + copyRow( + label: "Private Key", + value: privateKeyHash, + isCopied: copiedPrivateKey, + position: .bottom + ) { + UIPasteboard.general.string = privateKeyHash + copiedPrivateKey = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + copiedPrivateKey = false + } + } + } + } + + Text("Your private key is encrypted. Never share it with anyone.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + } + + // MARK: - Actions Section + + private var actionsSection: some View { + VStack(alignment: .leading, spacing: 8) { + SettingsCard { + VStack(spacing: 0) { + NavigationLink(value: SettingsDestination.backup) { + HStack { + Text("Backup") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.tertiaryText) + } + .padding(.horizontal, 16) + .frame(height: 48) + .contentShape(Rectangle()) + } + .settingsHighlight(position: .top) + + Divider() + .background(RosettaColors.Adaptive.divider) + .padding(.horizontal, 16) + + actionRow( + label: "Delete Account", + color: RosettaColors.error, + showChevron: false, + position: .bottom + ) { + showDeleteConfirmation = true + } + } + } + + Text("Deleting your account will permanently remove all data from this device and the server.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + .padding(.top, 24) + } + + // MARK: - Helpers + + private func copyRow( + label: String, + value: String, + isCopied: Bool, + position: SettingsRowPosition = .alone, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack { + Text(label) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Spacer() + + if isCopied { + Text("Copied") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(RosettaColors.success) + } else { + Text(truncateKey(value)) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + } + .padding(.horizontal, 16) + .frame(height: 48) + .contentShape(Rectangle()) + } + .settingsHighlight(position: position) + } + + private func actionRow( + label: String, + color: Color, + showChevron: Bool = true, + position: SettingsRowPosition = .alone, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack { + Text(label) + .font(.system(size: 15)) + .foregroundStyle(color) + + Spacer() + + if showChevron { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.tertiaryText) + } + } + .padding(.horizontal, 16) + .frame(height: 48) + .contentShape(Rectangle()) + } + .settingsHighlight(position: position) + } + + private func truncateKey(_ key: String) -> String { + guard key.count > 20 else { return key } + return String(key.prefix(20)) + "..." + } +} diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index 42e6700..a23a392 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -1,14 +1,37 @@ import SwiftUI +// MARK: - Settings Navigation + +enum SettingsDestination: Hashable { + case updates + case safety + case backup +} + /// Settings screen with in-place profile editing transition. /// Avatar stays in place, content fades between settings and edit modes, /// tab bar slides down when editing. struct SettingsView: View { var onLogout: (() -> Void)? + var onAddAccount: ((AuthScreen) -> Void)? @Binding var isEditingProfile: Bool + @Binding var isDetailPresented: Bool @StateObject private var viewModel = SettingsViewModel() + @State private var navigationPath = NavigationPath() @State private var showDeleteAccountConfirmation = false + @State private var showAddAccountSheet = false + + // Biometric + @State private var isBiometricEnabled = false + @State private var showBiometricPasswordPrompt = false + @State private var biometricPassword = "" + @State private var biometricError: String? + + // Avatar + @State private var avatarImage: UIImage? + /// Photo selected in ProfileEditView but not yet committed — saved on Done press. + @State private var pendingAvatarPhoto: UIImage? // Edit mode field state — initialized when entering edit mode @State private var editDisplayName = "" @@ -18,15 +41,17 @@ struct SettingsView: View { @State private var isSaving = false var body: some View { - NavigationStack { + NavigationStack(path: $navigationPath) { ScrollView(showsIndicators: false) { if isEditingProfile { ProfileEditView( + onAddAccount: onAddAccount, displayName: $editDisplayName, username: $editUsername, publicKey: viewModel.publicKey, displayNameError: $displayNameError, - usernameError: $usernameError + usernameError: $usernameError, + pendingPhoto: $pendingAvatarPhoto ) .transition(.opacity) } else { @@ -39,10 +64,33 @@ struct SettingsView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } .toolbarBackground(.hidden, for: .navigationBar) - .task { viewModel.refresh() } + .navigationDestination(for: SettingsDestination.self) { destination in + switch destination { + case .updates: + UpdatesView() + case .safety: + SafetyView(onLogout: onLogout) + case .backup: + BackupView() + } + } + .task { + viewModel.refresh() + refreshBiometricState() + avatarImage = AvatarRepository.shared.loadAvatar(publicKey: viewModel.publicKey) + } .alert("Delete Account", isPresented: $showDeleteAccountConfirmation) { Button("Cancel", role: .cancel) {} Button("Delete Account", role: .destructive) { + let publicKey = SessionManager.shared.currentPublicKey + BiometricAuthManager.shared.clearAll(forAccount: publicKey) + AvatarRepository.shared.removeAvatar(publicKey: publicKey) + DialogRepository.shared.reset(clearPersisted: true) + MessageRepository.shared.reset(clearPersisted: true) + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "rosetta_recent_searches_\(publicKey)") + defaults.removeObject(forKey: "rosetta_last_sync_\(publicKey)") + defaults.removeObject(forKey: "backgroundBlurColor_\(publicKey)") SessionManager.shared.endSession() try? AccountManager.shared.deleteAccount() onLogout?() @@ -51,11 +99,57 @@ struct SettingsView: View { Text("Are you sure? This will permanently delete your account from this device. You'll need your seed phrase to recover it.") } .onChange(of: isEditingProfile) { _, isEditing in - if !isEditing { viewModel.refresh() } + if !isEditing { + viewModel.refresh() + refreshBiometricState() + avatarImage = AvatarRepository.shared.loadAvatar(publicKey: viewModel.publicKey) + } + } + .onAppear { refreshBiometricState() } + .onReceive(NotificationCenter.default.publisher(for: .profileDidUpdate)) { _ in + viewModel.refresh() + } + .alert( + "Enable \(BiometricAuthManager.shared.biometricName)", + isPresented: $showBiometricPasswordPrompt + ) { + SecureField("Password", text: $biometricPassword) + Button("Cancel", role: .cancel) { + biometricPassword = "" + biometricError = nil + isBiometricEnabled = false + } + Button("Enable") { enableBiometric() } + } message: { + if let biometricError { + Text(biometricError) + } else { + Text("Enter your password to securely save it for \(BiometricAuthManager.shared.biometricName) unlock.") + } + } + .onChange(of: navigationPath.count) { _, newCount in + isDetailPresented = newCount > 0 + } + .confirmationDialog( + "Create account", + isPresented: $showAddAccountSheet, + titleVisibility: .visible + ) { + Button("Create New Account") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + onAddAccount?(.seedPhrase) + } + } + Button("Import Existing Account") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + onAddAccount?(.importSeed) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You may create a new account or import an existing one.") } } - - } // MARK: - Toolbar @@ -65,6 +159,7 @@ struct SettingsView: View { ToolbarItem(placement: .navigationBarLeading) { if isEditingProfile { Button { + pendingAvatarPhoto = nil withAnimation(.easeInOut(duration: 0.2)) { isEditingProfile = false } @@ -113,6 +208,7 @@ struct SettingsView: View { editUsername = viewModel.username displayNameError = nil usernameError = nil + pendingAvatarPhoto = nil withAnimation(.easeInOut(duration: 0.2)) { isEditingProfile = true } @@ -132,7 +228,9 @@ struct SettingsView: View { // MARK: - Profile Save private var hasProfileChanges: Bool { - editDisplayName != viewModel.displayName || editUsername != viewModel.username + editDisplayName != viewModel.displayName + || editUsername != viewModel.username + || pendingAvatarPhoto != nil } private func saveProfile() { @@ -158,6 +256,18 @@ struct SettingsView: View { return } + let hasTextChanges = trimmedName != viewModel.displayName + || trimmedUsername != viewModel.username + + // Avatar-only change — save locally, no server round-trip needed + if !hasTextChanges { + commitPendingAvatar() + withAnimation(.easeInOut(duration: 0.2)) { + isEditingProfile = false + } + return + } + guard !isSaving else { return } isSaving = true @@ -168,8 +278,9 @@ struct SettingsView: View { isSaving = false if let code = ResultCode(rawValue: result.resultCode), code == .success { - // Server confirmed — update local profile + // Server confirmed — update local profile + avatar updateLocalProfile(displayName: trimmedName, username: trimmedUsername) + commitPendingAvatar() withAnimation(.easeInOut(duration: 0.2)) { isEditingProfile = false } @@ -201,12 +312,22 @@ struct SettingsView: View { ProtocolManager.shared.removeResultHandler(handlerId) isSaving = false updateLocalProfile(displayName: trimmedName, username: trimmedUsername) + commitPendingAvatar() withAnimation(.easeInOut(duration: 0.2)) { isEditingProfile = false } } } + /// Saves pending avatar photo to disk and updates the displayed avatar. + private func commitPendingAvatar() { + if let photo = pendingAvatarPhoto { + AvatarRepository.shared.saveAvatar(publicKey: viewModel.publicKey, image: photo) + avatarImage = photo + pendingAvatarPhoto = nil + } + } + private func updateLocalProfile(displayName: String, username: String) { AccountManager.shared.updateProfile( displayName: displayName, @@ -221,16 +342,41 @@ struct SettingsView: View { // MARK: - Settings Content private var settingsContent: some View { - VStack(spacing: 16) { + VStack(spacing: 0) { profileHeader - accountSection - settingsSection + + accountSwitcherCard + + // Desktop parity: separate cards with subtitle descriptions. + updatesCard + if BiometricAuthManager.shared.isBiometricAvailable { + biometricCard + } + themeCard + safetyCard + + rosettaPowerFooter } .padding(.horizontal, 16) - .padding(.top, 8) + .padding(.top, 0) .padding(.bottom, 100) } + /// Desktop parity: "rosetta — powering freedom" footer with small R icon. + private var rosettaPowerFooter: some View { + HStack(spacing: 6) { + RosettaLogoShape() + .fill(RosettaColors.Adaptive.textTertiary) + .frame(width: 11, height: 11) + + Text("rosetta – powering freedom") + .font(.system(size: 12)) + .foregroundStyle(RosettaColors.Adaptive.textTertiary) + } + .frame(maxWidth: .infinity) + .padding(.top, 32) + } + // MARK: - Profile Header private var profileHeader: some View { @@ -239,7 +385,8 @@ struct SettingsView: View { initials: viewModel.initials, colorIndex: viewModel.avatarColorIndex, size: 80, - isSavedMessages: false + isSavedMessages: false, + image: avatarImage ) VStack(spacing: 4) { @@ -266,43 +413,319 @@ struct SettingsView: View { ) .frame(height: 16) } - .padding(.vertical, 16) + .padding(.vertical, 8) } - // MARK: - Account Section + // MARK: - Account Switcher Card - private var accountSection: some View { - GlassCard(cornerRadius: 26, fillOpacity: 0.08) { + private var accountSwitcherCard: some View { + let currentKey = AccountManager.shared.currentAccount?.publicKey + let otherAccounts = AccountManager.shared.allAccounts.filter { $0.publicKey != currentKey } + + return SettingsCard { VStack(spacing: 0) { - settingsRow(icon: "person.fill", title: "My Profile", color: .red) {} - sectionDivider - settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: RosettaColors.primaryBlue) {} - sectionDivider - settingsRow(icon: "desktopcomputer", title: "Devices", color: .orange) {} + ForEach(Array(otherAccounts.enumerated()), id: \.element.publicKey) { index, account in + let position: SettingsRowPosition = index == 0 && otherAccounts.count == 1 + ? .top + : index == 0 ? .top : .middle + + accountRow(account, position: position) + + Divider() + .background(RosettaColors.Adaptive.divider) + .padding(.leading, 52) + } + + addAccountRow(position: otherAccounts.isEmpty ? .alone : .bottom) + } + } + .padding(.top, 16) + } + + private func accountRow(_ account: Account, position: SettingsRowPosition) -> some View { + let name = account.displayName ?? String(account.publicKey.prefix(7)) + let initials = RosettaColors.initials(name: name, publicKey: account.publicKey) + let colorIndex = RosettaColors.avatarColorIndex(for: name, publicKey: account.publicKey) + let avatarImage = AvatarRepository.shared.loadAvatar(publicKey: account.publicKey) + let unread = totalUnreadCount(for: account.publicKey) + + return Button { + // Show fade overlay FIRST — covers the screen immediately. + // Delay setActiveAccount + endSession until overlay is fully opaque (35ms fade-in), + // so the user never sees the account list re-render. + onLogout?() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { + AccountManager.shared.setActiveAccount(publicKey: account.publicKey) + SessionManager.shared.endSession() + } + } label: { + HStack(spacing: 12) { + AvatarView( + initials: initials, + colorIndex: colorIndex, + size: 30, + isSavedMessages: false, + image: avatarImage + ) + + Text(name) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + Spacer(minLength: 8) + + if unread > 0 { + Text(formattedUnreadCount(unread)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(RosettaColors.primaryBlue) + .clipShape(Capsule()) + } + } + .padding(.horizontal, 16) + .frame(height: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .settingsHighlight(position: position) + } + + private func addAccountRow(position: SettingsRowPosition) -> some View { + Button { + showAddAccountSheet = true + } label: { + HStack(spacing: 12) { + Image(systemName: "plus") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(RosettaColors.primaryBlue) + .frame(width: 30, height: 30) + + Text("Add Account") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.primaryBlue) + + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .settingsHighlight(position: position) + } + + /// Calculates total unread count for the given account's dialogs. + /// Only returns meaningful data for the currently active session. + private func totalUnreadCount(for accountPublicKey: String) -> Int { + guard accountPublicKey == SessionManager.shared.currentPublicKey else { return 0 } + return DialogRepository.shared.dialogs.values.reduce(0) { $0 + $1.unreadCount } + } + + /// Formats unread count: "42" for < 1000, "1,2K" for >= 1000. + private func formattedUnreadCount(_ count: Int) -> String { + if count < 1000 { + return "\(count)" + } + let thousands = Double(count) / 1000.0 + let formatted = String(format: "%.1f", thousands) + .replacingOccurrences(of: ".", with: ",") + return "\(formatted)K" + } + + // MARK: - Desktop Parity Cards + + private var updatesCard: some View { + VStack(alignment: .leading, spacing: 8) { + SettingsCard { + NavigationLink(value: SettingsDestination.updates) { + settingsRowLabel( + icon: "arrow.triangle.2.circlepath", + title: "Updates", + color: .green + ) + } + .settingsHighlight() + } + Text("You can check for new versions of the app here. Updates may include security improvements and new features.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + .padding(.top, 16) + } + + private var themeCard: some View { + settingsCardWithSubtitle( + icon: "paintbrush.fill", + title: "Theme", + color: .indigo, + subtitle: "You can change the theme." + ) {} + } + + private var safetyCard: some View { + VStack(alignment: .leading, spacing: 8) { + SettingsCard { + NavigationLink(value: SettingsDestination.safety) { + settingsRowLabel( + icon: "shield.lefthalf.filled", + title: "Safety", + color: .purple + ) + } + .settingsHighlight() + } + ( + Text("You can learn more about your safety on the safety page, please make sure you are viewing the screen alone ") + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + Text("before proceeding to the safety page") + .bold() + .foregroundStyle(RosettaColors.Adaptive.text) + + Text(".") + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + ) + .font(.system(size: 13)) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + .padding(.top, 16) + } + + private var logoutCard: some View { + settingsCardWithSubtitle( + icon: "rectangle.portrait.and.arrow.right", + title: "Logout", + color: RosettaColors.error, + titleColor: RosettaColors.error, + showChevron: false, + subtitle: "Logging out of your account. After logging out, you will be redirected to the password entry page." + ) { + SessionManager.shared.endSession() + onLogout?() + } + } + + // MARK: - Biometric Card + + private var biometricCard: some View { + let biometric = BiometricAuthManager.shared + + return VStack(alignment: .leading, spacing: 8) { + SettingsCard { + settingsToggle( + icon: biometric.biometricIconName, + title: biometric.biometricName, + color: .blue, + isOn: isBiometricEnabled + ) { newValue in + if newValue { + // Show password prompt to enable + biometricPassword = "" + biometricError = nil + showBiometricPasswordPrompt = true + } else { + disableBiometric() + } + } + } + Text("Use \(biometric.biometricName) to unlock Rosetta instead of entering your password.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + .padding(.top, 16) + } + + /// Toggle row for settings (icon + title + Toggle). + private func settingsToggle( + icon: String, + title: String, + color: Color, + isOn: Bool, + action: @escaping (Bool) -> Void + ) -> some View { + HStack(spacing: 0) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(.trailing, 16) + + Text(title) + .font(.system(size: 17, weight: .medium)) + .tracking(-0.43) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + Spacer(minLength: 8) + + Toggle("", isOn: Binding( + get: { isOn }, + set: { action($0) } + )) + .labelsHidden() + .tint(RosettaColors.primaryBlue) + } + .padding(.horizontal, 16) + .frame(height: 52) + } + + private func refreshBiometricState() { + let publicKey = viewModel.publicKey + guard !publicKey.isEmpty else { return } + isBiometricEnabled = BiometricAuthManager.shared.isBiometricEnabled(forAccount: publicKey) + } + + private func enableBiometric() { + let enteredPassword = biometricPassword + biometricPassword = "" + + guard !enteredPassword.isEmpty else { + biometricError = "Password cannot be empty" + isBiometricEnabled = false + return + } + + let publicKey = viewModel.publicKey + + Task { + do { + // Verify password is correct by attempting unlock + _ = try await AccountManager.shared.unlock(password: enteredPassword) + + // Password correct — save for biometric + let biometric = BiometricAuthManager.shared + try biometric.savePassword(enteredPassword, forAccount: publicKey) + biometric.setBiometricEnabled(true, forAccount: publicKey) + isBiometricEnabled = true + biometricError = nil + } catch { + isBiometricEnabled = false + biometricError = "Wrong password" + showBiometricPasswordPrompt = true } } } - // MARK: - Settings Section - - private var settingsSection: some View { - GlassCard(cornerRadius: 26, fillOpacity: 0.08) { - VStack(spacing: 0) { - settingsRow(icon: "paintbrush.fill", title: "Appearance", color: RosettaColors.primaryBlue) {} - sectionDivider - settingsRow(icon: "lock.fill", title: "Privacy and Security", color: .gray) {} - sectionDivider - settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {} - sectionDivider - settingsRow(icon: "ladybug.fill", title: "Crash Logs", color: .orange) {} - } - } + private func disableBiometric() { + let publicKey = viewModel.publicKey + let biometric = BiometricAuthManager.shared + biometric.deletePassword(forAccount: publicKey) + biometric.setBiometricEnabled(false, forAccount: publicKey) + isBiometricEnabled = false } // MARK: - Danger Section private var dangerSection: some View { - GlassCard(cornerRadius: 26, fillOpacity: 0.08) { + SettingsCard { Button { showDeleteAccountConfirmation = true } label: { @@ -314,7 +737,9 @@ struct SettingsView: View { Spacer() } .frame(height: 52) + .contentShape(Rectangle()) } + .settingsHighlight() } } @@ -325,46 +750,92 @@ struct SettingsView: View { icon: String, title: String, color: Color, + titleColor: Color = RosettaColors.Adaptive.text, detail: String? = nil, showChevron: Bool = true, action: @escaping () -> Void ) -> some View { Button(action: action) { - HStack(spacing: 0) { - Image(systemName: icon) - .font(.system(size: 21)) - .foregroundStyle(.white) - .frame(width: 30, height: 30) - .background(color) - .clipShape(RoundedRectangle(cornerRadius: 7)) - .padding(.trailing, 16) - - Text(title) - .font(.system(size: 17, weight: .medium)) - .tracking(-0.43) - .foregroundStyle(RosettaColors.Adaptive.text) - .lineLimit(1) - - Spacer(minLength: 8) - - if let detail { - Text(detail) - .font(.system(size: 17)) - .tracking(-0.43) - .foregroundStyle(RosettaColors.secondaryText) - } - - if showChevron { - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(RosettaColors.tertiaryText) - .frame(width: 8) - .padding(.leading, detail != nil ? 16 : 0) - } - } - .padding(.horizontal, 16) - .frame(height: 52) + settingsRowLabel( + icon: icon, title: title, color: color, + titleColor: titleColor, detail: detail, + showChevron: showChevron + ) } + .settingsHighlight() + } + + /// Non-interactive row label for use inside NavigationLink. + private func settingsRowLabel( + icon: String, + title: String, + color: Color, + titleColor: Color = RosettaColors.Adaptive.text, + detail: String? = nil, + showChevron: Bool = true + ) -> some View { + HStack(spacing: 0) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(.trailing, 16) + + Text(title) + .font(.system(size: 17, weight: .medium)) + .tracking(-0.43) + .foregroundStyle(titleColor) + .lineLimit(1) + + Spacer(minLength: 8) + + if let detail { + Text(detail) + .font(.system(size: 17)) + .tracking(-0.43) + .foregroundStyle(RosettaColors.secondaryText) + } + + if showChevron { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.tertiaryText) + .frame(width: 8) + .padding(.leading, detail != nil ? 16 : 0) + } + } + .padding(.horizontal, 16) + .frame(height: 52) + .contentShape(Rectangle()) + } + + /// Desktop parity: card with a single settings row + subtitle text below. + private func settingsCardWithSubtitle( + icon: String, + title: String, + color: Color, + titleColor: Color = RosettaColors.Adaptive.text, + showChevron: Bool = true, + subtitle: String, + action: @escaping () -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + SettingsCard { + settingsRow( + icon: icon, title: title, color: color, + titleColor: titleColor, showChevron: showChevron, + action: action + ) + } + Text(subtitle) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + .padding(.top, 16) } private var sectionDivider: some View { diff --git a/Rosetta/Features/Settings/UpdatesView.swift b/Rosetta/Features/Settings/UpdatesView.swift new file mode 100644 index 0000000..f84456d --- /dev/null +++ b/Rosetta/Features/Settings/UpdatesView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +/// Updates screen — Android parity. +/// Shows app version info, up-to-date status, and a "Check for Updates" button. +struct UpdatesView: View { + @Environment(\.dismiss) private var dismiss + + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } + + private var buildNumber: String { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + statusCard + versionCard + helpText + checkButton + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 100) + } + .background(RosettaColors.Adaptive.background) + .scrollContentBackground(.hidden) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .enableSwipeBack() + .toolbar { toolbarContent } + .toolbarBackground(.hidden, for: .navigationBar) + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .glassCircle() + } + + // No title — per user request + } + + // MARK: - Status Card + + private var statusCard: some View { + SettingsCard { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(RosettaColors.success) + + VStack(alignment: .leading, spacing: 2) { + Text("App is up to date") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(RosettaColors.success) + + Text("You're using the latest version") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + + Spacer() + } + .padding(16) + } + } + + // MARK: - Version Card + + private var versionCard: some View { + SettingsCard { + VStack(spacing: 0) { + versionRow(title: "Application Version", value: appVersion) + + Divider() + .background(RosettaColors.Adaptive.divider) + .padding(.horizontal, 16) + + versionRow(title: "Build Number", value: buildNumber) + } + } + .padding(.top, 16) + } + + private func versionRow(title: String, value: String) -> some View { + HStack { + Text(title) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Spacer() + + Text(value) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .padding(.horizontal, 16) + .frame(height: 48) + } + + // MARK: - Help Text + + private var helpText: some View { + Text("We recommend always keeping the app up to date to improve visual effects and have the latest features.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + } + + // MARK: - Check Button + + private var checkButton: some View { + Button {} label: { + Text("Check for Updates") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + } + .buttonStyle(RosettaPrimaryButtonStyle()) + .padding(.top, 16) + } +} diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index a211a4f..2baa504 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -124,6 +124,8 @@ struct RosettaApp: App { // If this is the first launch after install, clear any stale Keychain data. if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") { try? AccountManager.shared.deleteAccount() + try? KeychainManager.shared.delete(forKey: Account.KeychainKey.allAccounts) + UserDefaults.standard.removeObject(forKey: Account.KeychainKey.activeAccountKey) UserDefaults.standard.set(true, forKey: "hasLaunchedBefore") } @@ -163,13 +165,8 @@ struct RosettaApp: App { } } - @MainActor static var _bodyCount = 0 @ViewBuilder private func rootView(for state: AppState) -> some View { - #if DEBUG - let _ = Self._bodyCount += 1 - let _ = print("⭐ RosettaApp.rootView #\(Self._bodyCount) state=\(state)") - #endif switch state { case .onboarding: OnboardingView { @@ -202,11 +199,19 @@ struct RosettaApp: App { ) case .main: - MainTabView(onLogout: { - isLoggedIn = false - hasCompletedOnboarding = false - fadeTransition(to: .onboarding) - }) + MainTabView( + onLogout: { + isLoggedIn = false + // Desktop parity: if other accounts remain after deletion, go to unlock. + // Only go to onboarding if no accounts left. + if AccountManager.shared.hasAccount { + fadeTransition(to: .unlock) + } else { + hasCompletedOnboarding = false + fadeTransition(to: .onboarding) + } + }, + ) } } @@ -238,4 +243,6 @@ struct RosettaApp: App { extension Notification.Name { /// Posted when user taps a push notification — carries a `ChatRoute` as `object`. static let openChatFromNotification = Notification.Name("openChatFromNotification") + /// Posted when own profile (displayName/username) is updated from the server. + static let profileDidUpdate = Notification.Name("profileDidUpdate") }