Ra*T0Iot)WdflwXlpz583C~KqHP&v0}MM=so9ew+4(X_(Y; zf$GVGI7!Q63AO&?j`+SQngBq;>%ap+hqNy^sc1n5A(
!`}F)__s5ZN>w+U&pT(;zFBEg1(1b zP-gBDLbRW@2Ky#S)3OCU3mmVg_vpP!M3V@-5006VI+lhNevj;AyN UruxY literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/180.png b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/180.png new file mode 100644 index 0000000000000000000000000000000000000000..4c23ec8f2c2eb2b1bfb82abbc779c683d7fbebd0 GIT binary patch literal 17899 zcmY(q1zc3Y*8sY7g93sy2-2~1cOwnbvDDJJEZrzbi-0V>bccjWgG#z|HwY{pvVf#K z{J#JDfA8J%Obo^$3*otZiJ+&EorRU&+9d;kDIq^_o<{}?y_3%J;iPw5eCfyW5b zQC>?P0Qi(baA$+{m}az7)7Jt3f>;57h&KSh&0|)?E&u=p0s#9U06+o;08o16w&_Vd zzIfwcq7K&50&qUUxBx6PQUJyyg!Xs?(5M0bxIIDu4K$kn!un|J|G_~A0Aig1nE$~s zdyM~GYLDR`&Hu(2aJ2v92uJ@fb~7B~|KNXQr6;#Rj}e}inkfVTARzk}&;Z%F&mXy& zIvbk!nrLZC*m=71+Sq&AI`9U#d;OyVNCikdg67E @r#R#^8p3;1O#{ 9= iTZ8jNvDyt0~06-3)t|V_5fOZs$ z*Zfj7(CVVoGfzv9*}jm@oc+fKNTSe(jprCT1n-_w^Spmb{n>;SoJv>lL;O=(bY3FF zM|(7mOwyZBZA =Vqt7eP&tFTB#pQ>?|p&&HD>K;@HlPLfu)@a*0hymlxh3V z$m_4tWVtHAV}R+VOTW3*fl~*CX)mI!) c>xzOFmCntjXs~4F2?$mz{>W` zyCZ3w{bvLYc7Ipvd|HBMt?*C7Br7DJjEPri0uOX5gz9%_xz2K>?UU!BwenWIT5hkk z*xKI#oc!A}(?Bx#! L9DKLV>iGR(*BvQx831MCI+6@h7H zSTXTwP7x^cpYm>iX* ;%asj^wP zJ5S%Z>{Gx-`RO-4iH nHt&x&^6Xm>pR4%@D%+67d2&ly{$+wPxYNrO}dM?%}1-j zDZhzQ(q$NL-Fuuy8_-Uq=V=0H#~#;=ux031{R?j57u%2 (Rm*hZisex5^wS5# zr#F~+x=4YG2};W)4c#X4&;HAyGl{j4Y~76WC-Dx}Bu{VoeLX?0hq&NID&YofOccLh zZ19VbQx4s;j@NR->ggZ9OK+4g&@kPb2!+Sty~uAQOwYE)#zN_UoRomFDRJf$IjNI1 zCge+laQE%Nv
WBJRfR)j8xvp}#}sZ<6!Ab(}SFY~i9hDc=#7 zrvpqU0EC^wz48Mw;bAYWSaAho2ROaI0%}3vHFH!$>!1bE$Mxqi{u!IB;=rNEpKaj9 z=ALuH>=eTXTq{@-G6v*=C$A;KjE?#aKv@Q~4j$|JF#CXOi|IEnkgxsJkW6%}WZys5 z_dS@R$VdOK_94h$JmcN-d7$By3+|vjw>S}$RZ!KD!(ReCd!70Wr%G)c?Z`L1(WpgE z+F53EdMnyq)*hW*3Y8+CH5QwDC5{6gj^oNYJzKIWvCNKfPw#*Pc(Wn!P%JlpUL(J) zx4-vG6zAl*Z}{~<@+^ESXsUP;8GC7U8Z1ehX^c_91uy$Xg7gnrFmeqdkhu?O5K#r{ z<$hQCt)WSN%Ts=-43Whi(-cu;hkV8c*0z}KGzW*}mNi}B=cVwi=s|FwbUB!^^@iPR z _RDsQdSytwv9Dq$k%xe@%@U`YB~vkLLuSO?pA^|ATT6`WV}Y0 zjdRR~o%=j66O+L+GKe pV{d>#Z))B)B0 zG{sdB3cXB$kFGw~5DdvTbs>GIhicJpR!-K#%H#NQLZHOIs!?Xm+gFWoY*VhLoJO)@ zr2@6E9~Rop&sy*p X%%)Xem&4J7Jv+G(def1iirK<@)=svIiRaQ!=+Oh&*z&oAdd zgCm^i1`vzlqbfqnkaP%^KQs8W>J)jCm=$Sg`&kHz3PduPeHwvF pjo=W|BmQ ^))4ZO6sHr0Oyd_lx!Pcld&y_EB|Hf%pC)&me=bW0N*vAT*79;b94d z4|Apu4m$5aBtNH ; zhopz;q&Cx8$wz*_-84}$f`lz_%kns7cQB)VcX{*W&IfUI>kJX{;Hk2opUImqtiXFV zlv0N&W0db+1Ei?deJ=P`4XQ%K-(@g*^TTHGbg*Fz*g4*!R_N$exsxo_$OWhc;FS>G z$6a-~E0qE8Zqd7%#uy=)c7m8@`A;s8NoA3(;6k+&QCEO2R&-98-eHSq(OvUByzTD2 zC5_8US6oy0I|oZ#VgwBu*ag4^5Uhl(zM{zZdDs`dRXl0GnAOV}zE&IX=_)Og$Ki`* zi`z5a3y?NBNpvjork!D4v^V0ODf4llXpIsczGU#q3(}e-c%O_m9ohQqfG$P pnkDN10JecDatA1SliP$a%ip1yTO2q z=W-)P<$C*J7b=FtEnWujAJ*T#{L;#3yVkkHLee`rZAFXMwwuOuDd`0|b8LENi!@3) zJBI!eSJ~Idk=0LfaZRk((~Ob;+!ppY|Adk1bq(x;Wcooj5ZZC86wl~7-^BUEGlw!m zLOnh4VQ}ti5V%~)^8KoEF Tk7!V tbq!~miR_Ovc2-i|HH~!zHPcK?mhv4&qnse)D+fW1Xuo<1F#c)4iJF9k#~7^ zex^C}Q{VOdTOBbholba^V|xD_^!S}{g&kzjfC0N@ODT1dq@|p-qAx{t;DrS`LxjTH zgzbZu84rj6@tQ~%K*5`@r>u_EM{#(YL#lr`+Y?m2jKJIiUCvH|TznX&HI#$*n2<9* z^97yh86x!h`4NHAUBH1tw3@8r#FEKjhHqcP{C|5vBlUq;!oiAKI-!i %;O76X@Yj;r2_8`HIrW}u3V=oZTfnJXY zG$FrhOi3BKI;>d^50luc6o(oJ2WG42nHP4J8=9pAbsy^e JXDTE=r}N(}Zh zZ{_8lo%o)mF|&p5^7%z&$eya_g? 1U;o4CnAc{cH)(B#1InH4f zTV>ET(XA7_uk|F @uA|8D08tWOZL51l)@C|pVBwzHTJt`iNu5J0$(=2JUebw<$f~lt6u^4=s6^# zA;g$mek-g4qlCVR5 lw8@4M~lg~Dlqq)T6o ze$bw4M-y75j-ryw4_Zk;Y#mtHBDBSnz2Us~D$2}X(Z|$y{?45YVgE74lH|IjzmF&L zO)f5`>1jq6I2uF(Ww@^XOr9NtcCv-ZHEGZs2jtC%jnv^)fgKzALq?_OZp#1qJ5g&r z!;j$w+-nm8dpd5C393Z`(7VTSDwpbT &7j%&sLveqUPQHJQoma!9#O zTC#c?a#d_E0rHBkb45PcXJLE>8)7!!8Mv-3H9$>0Ve=5G|G_A`@p)iexhJg6M#dOu zcR=!$O9o)vHJ5Dwn0S6?5Kg&dH<*k4d9zQ>k~HKws0lJr)3*6bqd1HaI8$veL_H>= ziBf4gtt^+X%!;XoLDgcL+ePizx>Z-=clYO!mR`HznXN8WHeJ7e$KQWpz&rf>MZW}R zn?TMI>uZyld1I_yUSEfPoeLJpq9BYs8K0pR6gFH(GHY9UhYgX|w5FT@*A@DU>^~{+ zK&x(9`xJ)dx5Fu4DBgpQr0$Wz28uQiDPfSi-Az_ihRkZN-q>dLyJ1(viU)x5m+u-L zcQ;%K3CJ8abX3zH%--(s96Y~}bnF}_iM9sb{BH*4hX!jk5ZU4fTcw?}_tIlFoj-{@ z*w6s*^jOyJy9}S>=s;oGAp%${rft-ktRqWIn=Re0%;`*{B;{PJ-+Cp%WD1qr4+v;g zb1h80bRmH7=LX }vWA|trm^w`Drlho~g&c9Vp#?*a)ph#To5(EdKN?#vGyZ86%Py2k<{yzHy zOvoqajw|1Z2bVsyh &zqb z=Xr*_@=x*zuje0@a&k-a&|iLuaOkRpzR__XjFU+aaYN^N8DP6~eo%IlXyt(>JZ%P} zUL^{qL4)F#j&2kh^lECEj><%oEDCFu(CUQB1-LYD#u$dZ#9=_>{K17$0zCA=UcO-< zwv@sFok;4j7emkdbJk@$=%g-Gcc;i8ssQPva&T9X>_gz+!1RKCb%OOuQ5&WOJGjZO zi*x=fWwXjiC>h>}L<3dIr{g-Jc(%`4(Vx@iNckjJZNb>t9Rx3=HODu;9r7@W;N_dG zVexQEappYLM*`9pW^s{4Y0@Qhs*8(jcJtN_6!iCX5!5w5;|chICs;Y3u+p=}Sx(+h z$Zq5*pI;J;BRD{RaprIDJLyY}E~7C%7-<+ueefsb*1^tBupL;Ltj5o+n-V08gfL%H zn5fGiV4mp7gXnQ!^?h@1trbZV&)?5FS`^D-VPmq8 v@Gr0C zQ-ar6>oJsrGQ=Wv(%*+Kv|_($;ZEKT`erI f+Aq+|PI+kuPdQY>c*tKn)0y z_mSyU3$8jJKxdRzucmHb5YQ_T@yEUg=&}Xne85Wui{)pfFT59M*YR9rKKq3Wj=K3( z^{F6Xo+K*h F^t(Z(&mBPoM536;?=X z6Z{+`;FdjMyQq +^Hv_0Fkf1xaMn`2yp(LiY*qb6uSB_Yg%Aw#80kvh8ugA)h zey1D32vLR9RqaHYbiUP|w}K&T*#^!k93sru0i8w~_lob%!A3D^e5@n7BGDjlctB{8 z)~=U*GIdZe(YsMB7_Su3-oQ!7k9pC~*X&R_WkvWRe?#t;97QFObf}Pgp@@QZFfF~x z4p6TnEX%$086uW3+kBffVy=(9>2s6X?FTxxKwKB<1S&V`mcBdgbiVcSK!Wbtp7wen zZy&JB(9Ok1E}bKFS=tJ~Kd{YPOWY&{A)3}H=Tfs zPU2~k9HKftKvgi+V29%?+4y3r A=3Fnqg&Y8pUM5DjJJI`kxv39-2H za#QwcZ IT*_I(UhU1Xt1DWRMZ2FZ3F~>C!z8T=-WX_25GYB zmz$qo`nc7Ny@2hQ`GA>{7g;}BkF~oNZ~anD`6z66rI@AL)nOW+x9SsEY8ozt%UahD zxTVgqqR;$Dsh{i}e<_t@tHeJgaLzZ1Bj_fm xFNr{r6{E*i zZZs_%^`j7^g48A(nB>D);%nCOZc %sAN^BE2~ z+1 3t9H@ls$_so4PuW#=?C@ zbDvGQM`nW#_!tvU+(>0N>cZPNGJc~}xiluEwWw!+%E#Uljmj!(Av^8vj0GW&D15lm za@H^=)^Fc7NA&d%`X9T8Dj2Ie$acipc5hFUkA%J_(|Jza5&-UyDs)#dp5aZ$<2(Gp zNybg@f|&E*NAl^wNTfv^j@!LgLtgr}joMN_k;4KH>!F%$p}u0Up!|=#Jla!Jzs2X6 z^|h?ND}7pG|G%EK3|P*S|2Q2;T59kTr`>Z7lk;wh%6lW(d^7i@5VjaNntJ5 z;0=F4v3!W;HD&=;(l!1*vjtVHbQsI@z@85e^9!pQV#`zWe7) QV}i6e`4FP zba%^&Jp#vef76%J0~W4cf@JAM({k#4KZZh5hK!6c<#6*w@N^!s!uI%>%^eU<&Tw ztY Yu+aLeU&C6yMne3xf#GWk#ELEaosYMaAO28B2e^S4kpQ@W_BwYMjW3E{ zh@maG>jDih0|)~#TTvXS8x;Xbljx!8nax)M~@5qYH9JOfqj{)?7K2LGE7 z+bg(s7ywQ~2ZvH;?sDC3L@)l)sda8j78vyTV(_!*7Tx9MgACfE)N-{TR)@zOo0Xej zv%B8C5NqHT*W{0;s`act3k4*{UU<_Cg_#me;jb_4Tw7XCDNhDi&gK*^qsg-kJ>H{5 z=q{ziI|l8`EMVooa#pEw$8N8NWIhW}&>+3WL`Tf>%9!FJxZfNOY(8f+i?`ef*&7lW z9aTnW&(qnsgLI>ZNsT4k&y%Dc4Wv5;X~6yP@xf;Z#H*#m;tIZ>noT31K(qReMq#ZR zJzFfqEvg5hYEO9X7mvECwkx`Fg|YwrG+m>vYey !Wt(>{_lzUA&cC@G;OLf&WB%a^r!|A zs^Y8=?#WdBG)g-ENfL?Bl5^(Y*U8&c!Rg>c`E2=MaCXG72)%LvEmz}_!mGH&Z-NOy zMyJ2-T0@<^+J7o4^zbO_M^U8uB&;;y ciBa2(a?($u+OQkENNI@b3X0oNmlH;NI3Q}?oyoQ< zopG_@EJvm(s}u)=0j804`=(A!+44lW1(62LjjXpLVjgN)U%o~`dJwmGAHO?Hgje+2 z05 *v3|N=-(eQLUjfB(1u>>0@!y^jy !DkjH2mFC+-LpbICAnE-CXK3Zdl5Ct*mTb6`fK=?cC?2$9Ky;$ z&j5)n)Fy*^X#?|#PSDHs3|8%I`TW*aKvtcd;IOup;LWSv?{T($hhD{s Xlm>pFf
o-GfCVSEZf6_(yxTVhAF&=_uhWJ8B0R)yrgtUk%G^uG5!kGmJ)(9IuKPj&wwOG#F z^N@w5oDTY`p$twxZuZn3sNM5S>HSCZocvT;0D>!vTD<_!Qy~;*0OpClm_RjLP2vol zL lhj$W$KZ)CNW z3KAueBP0r(#xiK8w1jon34a9Qu 7<`8vnp$~xZ1L7+TMaET1eXMubtq{!A+8=Ef4Vf^-UBc2PkHhE|&z1t9W zy`HZQeUGeP^AsT7sm{+p^|3h&Ul_3j?th1;=d$0xFNNbfBe^cW&c6M)z^q{r_*-Cp zKpVp;CHo#psZgq(0zyK9wxpeJ<<0lAJ|VxAOKrb;E5}w5%hTMW3H>~nWs@t5>#UNM zUwDMmi4lr^Ol3{HloC`C@|bq`Ojy&(fGI#);CEa;A~7d0bymLelQZ_W9+kZycL|4~ zqbjJ(iB|}1gsW_-^D20X19};nUOY_Ma_@!eF9pdqy1qUpZtoCXO7s~!;BC?V*tK3c zq+aQ4P{?>NSJ4=A&-rTj>1r;uTvbzsK<0Zv|NUlq4&Ukk-|fcAA@hvg-#-?>a&-^e zH{X`4HE5_Wu1>!#RJJl#g!7lNvcZ(KMx$Q1z{0Jz3iF1~evVTPr);)Hh}VtM17O9f zFS*9=g{$h)7k4T%m9$nvYm4#LGpSK_>scEL^E`{va@PmLgfgY{o>W5K+X*PvEzT9| z{Y6XJN4wg QscXrM8?{@;3T3W|*#k;LlP2KY_&t+RgxelM?m_;*E zBJQSx?H-=+#>MG&qB1uurOTSWQn>qp{9$O{O`?0j&f3Z 5sz?|s-O+ORCAVZ!H5 zmMrU!{aQVA-~2OFn~2fKlJD=ucx0BezwkVveycI4$V6}{cgw`DW83BI-OAwpw)Wwh z#wXdl)wD@-zEZ=eP~eG2WEP)ojC2#l%#rt#2o=lEu?p8m8OU$N4Da|lEcxNYGYbUP z?`IYim9n7Cdt`yKtc|{GT=1^B$TpX 7o8g+rZ@~R{GlE`5TE0_Wp3n|t FBcP#2WsM8H=y zuYB1dPrlv+<)p>uT@*=d)C97vrbh2b_;XTcmX-=x!-eO?kDJckgt9F|q<15D`c;01 zY14#8iSx)D1X7PlS%?U~J^9ul?%|4>%cd#sPO0xR>i~K1$FS0E@vrvC%cx5UwL+be zu2p5 ^c5Gd2!57bK9}^9 zMm+f#FVXaR9kMS>lW1+(a*6tNLEcXBwR5PwNFC##vW|e&%Za4d!Qe@S;x`~h#Kc(a z_%e%bQ(fA#Ck-l}uCF=0ip*nc`+tHW&p2jF-8b9~_1DHq3PbrsOhM<)IIx)ov1^Gg z-B*UOMtRfnB|8NYKa~}@#)f6}@V?oZjGJM>#=b|*Zbk?iXJ1Kc0&nIV%=$xQzM_6^ zxSVkWI7#4AUAz9C5$huF@D0lt77`J3Cq%SK fV54B84NAEf&jK>WvMRu6Ist VuCy-IyrkH>zMYy-jk4FkaBL}*m<3TT4jXVqAN(Rx&VC$aHSu)Y zSR)jdv&9_YD)E&r&CK^gHpt$1p-JG{N)93tH8$K;@_U>*n_vHM1HDB;yM%IF{1796 z{v#mwGysg{*vJR{5okS@<~(dc$*1_J;~TcVI_pZV5=U&G_Ac8nUkT8OHZ*?(^=E!> zr9*Y(`>;hmquzDP4bGPOm763>5tU^v>kZ1s0*l1{Iq%9ZrxWH$31s5&6wp4?vH8#j znzgxDN0-W++>=-uPnfz$4|*2ftuOFJwXdf}ve{<_|8;a|n9=Q2aAL30OrZ2dZrvZ( z;6!>?q@(fJwXO;1?Zzfxumz521buw$L>{OPWnlg+(KH6%y4e0*y0jgbaWV%AFFWiY zDO7unFT;wBEIrS)ZU}q(0qyyw?$Df+?pVY=-R>(#$|`xQ7u#9tS!^R{fM`&%)XvWK zNc)^Bd5H|Gm5g;O_CBxn+_ULng;EM#sYV@cRR)nLKC2!1G8P!#e!@oYE#$>L<0I%m zYzjio@r^8e4ZBP*G@nA!C8Vciuv+nMj0nGlhFLa{d_BB=!EHFEUv|}=_PTKS3MQnu zK Ew?t)4CiZ9n>JJDUE`#gmQaK9>fRq~21lB>qkIn>) z_q(YI13j8!*XG}n)^YH_eo9{>dKgl>^3(Lw%8|zwDat-BpZ(*kC&ge7_ysOersM{J z=~ z=uzWKx(O@t4wYC7-|;q?J)2ucKxo|J*3 zu=&v~iTWrDroE(swq7vRFCR~^NaE#2Y{y1&t &8G^i;;c`lx6?yXEEcKF)gQSpdQj zu`|(L{(C=hBl!7IG0a#ywGG%^pgp`<28Y0>=ZdKdVy25j*&;i3g8sAtgk; )@w-u;a}>*1Rt^`SHFZ2lH^IP5*b8#{$}1$S;FdV~d)MMxj#QR)y=ijz=z;xu zsI1bSGfG}}4Zzh|CvQm_!JnVCf7t7Zc|tuV9C4o8ecIRmZJFlN&QFGjzFSA8ir-O0 zAsa`WB-8Xd(ty2K5- cz#xdG<#U5lmrjtWSTDf-9%fF9BK;v>aqIS02m(7rC;hN$WGR zf)+$#nV(HCzCzKfhaLjrj2|m(FUy<68XrorcMbBe{qRNyhz%1s-J{q}z?LP_zxK{H zPSNB^&l)BUyYye0dlM#D(X-)GXTI?wa!CwA%?r=>2Rl4E2 SspBC!x%xX7R%3q#!yb #OyOUmt!f>EaIf_Xhp5dQ5nA)hK*b9zIg zZYXN>+&*8jrdY6)%)BZngl@Lvy9Pa&ZT{NBb zaFB!Ua)us}>1kAIT!he zGEFa={JJ%zdkOOC>1n*ry80{N+WzCthXSKBUj|3aM_F9nGC^ys8{kaTIslv=_7@n1RGpM^b6xljw$IdZjk7 z lfNg06~+%=inTM4;3~vLeF2sXIdqc2_t+JDPX* zJiw{cAhyQ_lBFL=kh2cTW%n5{|0sL>PNhqR(KMyT^>Z)DhvbI3C@iw2l;7j$hAa5D zd*1nH$3K)HuezQ}0nWDjLoQUjooP6{NH4Tomy j+OxTHDe2Sy3|IDSV?*R`6T_9((S@;N!({pM0Jcx%P9Ucb1 z7g(c+x|Y{n^6#x`%YpNiMrDtwFXdHhF7z$7_QY|^h2p|J%tyCB(aOLweVMPuA%7Jz z)5T})o_}>%QU^rTROd=q1mx(ZapC&)ir77k#~99XSTHOZO%`VBV5F72hZ#=2Sy0H& zM$u~nsy{Btons)lt!1xsKBdX=ETbF 7#J{S2r3sT|6jW_$>Ph_K-cW~)eI(A{{=epu&nYk1 zaT#A%eKBYk;?tM2tdyOC-Tv0 QgzbgQth%heWCMB93CN z17*<0uIm}#J0aSfZ_B$V!EniC)Jz7SH3xQWfyKq8Eaw5?;urkMY(?FZ5l~|gHFs=P zQ=TgGn8^1}H=WXAl3(V*gtHKt7(eWl%E^?ME9#((%7}ZIVCL&^WEIBvkB&hpyYgu9 zS48l6un8e%Z{{DnhrLkg9ey4s2`oWzGc?pO1q`o4K45e$-Z7P7jc_maBAstp)S|ci z;=ota)m(s=(=NkK)~NXPY{<~v+s?t?<1j!N>AqV_Him q$@{PV}(KQZ*lzH>erj-SfR{F4Kcb15Tz zS2!Vbn6<-<`Vl4~V)e0sYNSNM9HF ?r;X@S8F-U?jzCIN2kmUn%_S6P#ZI_>Z?ueuoY1&k3H3GlnE;8K&QCnJ+j!H~ zlAP|fLYRc}t=@)Iq2#C6!MAd$<3Az0-?(s*p45=*vDuuZhXYvP1{M)y(w%Z-N7+q@ zdRzTtHKW?AYHxkkw~%Roc0~Dd(-sI!H@znj8Jgobt`zKY{3e64nMXO9J2p)rDsVOg zM>?3S*KM6 HfkSa|tuL`F?R-XF^WE)<{`H|QfTc*}9 zDHGnVctf#bX`F%MnuTWG3JL =9`D{W&yTGvkmbkKp78C?1&7`p`r%&FDy$z4BsX+AxMI&wNe`zs|Z zUcJO!&+6~>tvdl1`QVDk$k`tWkH!)%K@h;_J+d5BRu*no-mn)`G@*e*g8rCep#XkP zM`hXl#%U)+Gg~#mY`UsPD2AW;b!Z4Or-P}lFz a0NTYSaakLA{ylhb8|kA=&JmAjjq;C?uGxbb z1?TQ4M}K@e_ESIS35Jn;?F- (`MBU!EVW#mQHS!(sJ^z^88g zG|`Spi>bll +A4 zHcV68-H&XYxI21+a2(Ij$Px#ItwSdYkW&Ih%wyD#Zm1)=!yTl(2$72HyJT2)Zj8yY z4{y6H%=jig-L%JosP_&}Y+ P%YC zv_pqEIbXiKk>h9?Gl2MLa=a@V c$_wBLJXIL`VAgXhj_vw<~;V1QZ$v z1V5Cj2-b^*7rJBDj$SUbD+VJo@WI_oGNpA2TE{n^qSg2!XoHg;J-YgB8)$y}NF^B6 zqqoazxtgmTtdi@dy{EL5eWdhol`D8LPV&tMYM5sH-j|;Isu9XG782TdZQkuU@{$Sm z=%g8(aUySaLQ&gLkm=x!i}`3=Ubb5UK90GGppRPRWM8EV&!ev&=Q5hp5V_O)%W9!b z=NfrZ5oR0&V%S3ioBUQlw|pcmsjqwftv2C83jPtpnSESgb!6c(F8%u~`F6>QAw2MK z7GJbe@?>H`FXri&K`;PO{!|Aj_#}c*s_g!pKE28dMj%PY_AKhHKK-ldrD&enRqgt zaxC&WPS`P|ap_)|XnJDb?F+Wo`+c|Cpzahm!RV?X=_Qf`XIwgwk;1*qdK?&&V;6c+ z>spx*2ASL$LI$?*`f*2%f?e8KCGE7 kl)6Qe4UXi{>gUm+6kRw9m| 4H!LQ;sf=wY6))5)J;oSy3NBqjz{4kV_FZhV=z!} z@sUrGWRx$)5)756ZdH-R5MIA4i9xH%QTk`VCU=YCLAlyrmZQqwMBtTiS|@nO`gT0V zUd@m3`X8d05=2; q8f~vtO5jIn*LK_$Jd|~wbUyUm zO%tr!lA!PQ6pBlW24^&96l#*ZBEDaX*wcEpyhRB>8h;%mSB_$(-)CMRDV<+@;zDJ) z)+^Ma#J;p*ueV%o>lTFK+NTg4yH@?{q11~QE3El(HU8 %*-YEuAeFDsi_KMhi~m z*6Za9dmU3u#&vGl8r-V(IwGt*!F#EA35mm#E4;x}W0ldtW#Ql(;}!5R5q(tL7Oa)} z+*(UmwPtBT|J+Et>d;?a)RGR#_TF)P2H&lFo=Mgi7>-L|R{m{fp$TX@owR TT &>4kFb8QDjbhDV6#nitU8Sjt zyJ5c*oLhJGs6`2@=9@kmj-Wbv?AJY7TsjqABC?MsWAqBT(iq27x=SYV;kaMeYIbdc zD6Q%t-GRK~@$@5oGG!o5z~{&1rvTF_-VJlCaIC0m=t>)?*TaUoo_kvAH;nDH><^P| zS^|n&2-# K(JZHav`M`fch}?CTsnNtO3=|FFoNPoq>a!?>BY5Gib;l!AJT zu5%AYAwjVi7r-Td-~9sgKM8&|kwr1wabsW;NKfQ7xy&ww0UuhTP{o?`bp2ULu7u0M zd_M$EkMJVGsR{*(N&|nM c2hp#%%MzTDl1`UQ`zrhu(RFbVbN1IX z7-`}r-)o%MCw(>|jqBVm iSJ@@(~M1qkF%N>$xQ6R zJ5&^0r)QY9bAGWP%wY&L5E8|5?x)59Yd0+RBD4)|`t4E~P|Y~VANUbKU+ZM@o;&KI z#(q%H9)cuSH8lSGM`~bc>Fa4J=CH)o-%mZAT)!zn{rWhxKsjj6*xT) IYx4zHdUxX`$ jnWjDIelk^=La_!Y-)lURv0Gsjy@1#nLf21smwj4@3 z`G-)vnZWUtguTwAa~G|_$itep? `{|H`r;cpPKiY4qtXDs>m`ZW z3T9;;WlZxraZlT37Di4sroydHL!Uoe!KT=GvftAOhm_`zIkG4UXdm>Rpj)C>WLt1t z__3dlp$i9k-dQxJ8BeyvBXN;&x_K+rGZu}Zi@}&_xsYe@s}We= WSCE zZa%*&F3_IYvX?z=Z0*$fOFo(M)HsbRDRq?U?L*b*U*>smjR%L8m|hW@PAj}q5xyH3 zgqIGQ15DyYK)-bIeV>VFl*`TiRu5zsHf20kjB+=*A-@Z1Tve!4zKML*Amb_0E!F1Y z(|sMfKjFZwvOpN}YocKQ4Y&xN=C9cEL0XyfU5lfYZ2sU6o;*bBCM=i2#b34ecj{2H z7@RZs^-bO4^Of$;-OpVg>SH*Z?Qi*u;K!d6niGsi^&%CjTd&^>RAV=gg^su=@2oNr zxkXJ*mLAF6ay{-iv_q<9Utb3|Dm6afmonxRWWTmf8slY}WS@k`!Rh&h1O292ATb*i z045kE2)#|h(9uy3>~6qUOIgrI0jdKaZa4JMwF90=M4A@VLRl-`j&&2mDV~MM%GHjk z(}ymRG%|Ff>!!TV4!L4`D+7Gg|Kz}_lZFPWl0wtSKk |SP;L_O$Y6qsbnm-_h{~WM<43Xs!UeV8}&-tv8j2K&F{h508 zuDnd`M22nh@qf%G^{fo5qWZnG1r{TAfa0OoPpkTb^1{$b{+2t{Z3KXior~O5zJS1S z7CeeW2v5wW%jPU+J(o5fL^UPK4}bBmy*T-vcX^YVR@e8tu?gKYwzU;)%VvRNF4)79 zU-dJiBGJG27QutWwGOTem6UIaChYX{io}D54}~9umL+N_fwvci-o>LBKS+UV0mEZ! zds4MYiN$ActB0P#Ls}`NE(Qu;>}>h6OAD0>r@5QA%J(9M3<1zfmq8z$$3qTUeu!;` zg!8L5_sVqjY?Y-%b++u=8C{_{zDG~NjC;^h70*%GU}f5oO}@uc$=GJ Zh17D3 zV7WYZazr}#-N+~8?!DS&G#@uxJP|~&OTzYc!q?^O@~mmG_%~vs;7cZ>COIW$^^xjS z2;wd-H1?Im8!oFDobhJz;>eNcOAX!nvB=i-eK!{XbG0~KN3-1`$i>1es1#x77z_G* zVu3z(-F3ccU%_gfAVNAl6y$P$F?btxTAvnJ?D|x0GFm1t$ cPwq;dOoYoL=td zK&q=OZ~v*)fs;|Qy~YoV3HIg!WsK)~JV_`6!DM-jt<`|#7T+(SndPIr|4#sA1e*IQ znv;^s|5s4|i{LoENw3B8527Ao`{9?S_17cs>3P=C&mWx!=vQt7vB4aLv+)Z2Gcz~` zHp8_pu^(Jm)GdQP6Dr*_@3))bJqZ4(@xVIh3Ju#ifO(-@OIaGF8Djv`9{cVN>g>Gd z_Hs|>3!MC}ar1>pEGb_QDP7OYwb9SZ4SJ@{X>0>A4w?8rf#>`o zKD6hV$6Q2qn`!{e?*W*%V}7t5grWzxSL(en000AENkl <1@`)=d ncF}N7ptL7?gau`#?l}8Iot_>6E#{7*ewv>?FBvM z5Q~{82divQ+YH&$WWRk+Xq(Y*(OlVjX?ms9)F@8$V0 UP*oOhR)6pw&4DJltOO6F!0jjgPLC?wqGaI9nni|DL z0Pv(|`=OLtsc(McnW_fLJb|`84bmWNhG;9@TEzV9EBo$nQfTc{QtA(a;h;SD{q`;_ z3f&blA7nqA*LzKxdQ>(eoB)!|0HqSExs^hhlNDEuqEc$H{oodR#r*O4l(_;h|0?#$ z`2+0G`kS3MwM EugEb zqku6%{hnY~3)JHnxU7j{1ZFBt#+_*H{!p5+QYkars)3*ues>n%sG9)l! LjS+V4j4b(QZrtW!V&^8U0pg z4+<>GQ&aj;f%N0F=OTe^ |B*hQ28 ZuKawIeuz!4G1Mr25E1-+z@7;{hT{WvSE~^m;86(L z4pQn5*n`1`(N6vzu-`%-T;iHYmg-y>(jLHAV!^5`DSE$seefV6c7SF-U_aSv5!KnF z6cp5dYF7iue*<7YYyOA5&^3`?t#e_>dw`%OU(N7<-G%|)-D=UOfms{Iy$cy0v=_my j`C@nO`T%`#7KHx~4$JLW?TE;Y00000NkvXXu0mjfn*$QO literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/60.png b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/60.png new file mode 100644 index 0000000000000000000000000000000000000000..ce09403648fd55637ce97279b5e6aa4d999877cf GIT binary patch literal 5145 zcmZ`-c|25Y`#;vPW{WJ@rV<)^wlQR1VzOkJu| H#)YklSM^& z{DdBkc^?~cM-{jV0F))s9@
YOA^|e9*#Us` zld~}bgMjNn?L6E>ZS6g7qD1}OJWo^r%pZE}x}h+(eEx2(?r5mLBKQ{rdhDOTVqm^s z6pV`^7y&onQ}ggf@yUpai;9DlX!!W}VBYo)P$PBCzvRbnieM)U#uF+g=I7@p>L(@Y z;q53UAulg4CN3!^DJgQy5J3mHV{H9J+|lQLC;3kvbrjmp+u0N2?BULLlGpa8hYv;( z3_dCJ_w~C^jI+a^O77^t#yTEQ>?9&4Au2BR-()C%=l>x)iTsxRn(KEt*hw-d(g=n2 zaP>K9OG#D|_N&7G1ON2*TkwZ(;_i%5`j6ya@V`}vKk+|h{t5nDpzrOBI{uKq`jYso zt$$+w;GawfHE{Mvxgyk^-B9l6U$e+vhKc>x$iJ0p9 7roHvljgOPeO^BcBu7KtSZ_Q8b=PH6Gv`=1{ zgtMCW$3(tO mbLk-P!W* zzURoJyVsPGD-3aNW)fNi*7DDyxR^?rXM 3zOh6xUQOh`%p(75H3qgWHzXu}S3 z#b J^^b)0EPG`1CP|*8p($W0Drss_tO}nw-r<&F)6gqVTYkE!@v^%{b(5JfI zTkfK+00BGf_S{WAy}1UxAVO4J{ZJnHKxonKXnYq>nBu90ol%tvv#%7_DF=cGn^74% zojj%2PcQMejd|$efprjHx*ji;vaE~z!?9aWzJMm2UtO+SlR=;Uv{p@FS%2`{CeEo= z!Im>=HM0g(2kK;AQn5S$eDkJ}i_%V41HYyRf8pAwMs81J67mTLkpi4^`$d-L(?7Wj z#V{?_8r>n3--z2G&EO`KGjmCSc##xtbO&!#rDTfu6iBM!GTIzT9sA~ld^$~KG2*22 zmdb6h#q7@wI$tYypUZzOMOCzX#M|iw`4fPx;znXO-PW~?oI;4V{2FORWr|hp3X-;I zb`G8B)duhoWe)N)w|&E=PxVd|z_(5E)#9r`iEC;Kstayp735bv6-az9u4ju+Pnn2E z_z1Yhs4ni4V4C&nP%6E|XA?l+C1sBR=;u0@OIRL*-tJ_>8bY-K$t_#%3zqfK=@p4d z5wYPhQg?iOJHODA^NeH_22=IUEtq+$kI0&F3y`mTH^iMT!%uW6A;TAHyDK{`?FS5J zvQ{<#ZVYX4dy=@@oe@`QMsCx#H3*AfUM#B?B?t*EZISj0S&rO!S O)v+B_FxX$?-DiO5Faj%jD&}VnRV{cpQ?kb zQ>kxlo`PH~GjXu`Jl=j*vTl&~L2u;qJW4I;OtI?YtTPg{_-Y z4PqVI?PEVY9msVrxSZDL_!zgUwOq)=F%rTU1PWW^H!{DT*6nOo>9YgN)z;F=xjXL` zA=|zF-kDRV%F1C{VwG{X=7Ak-H(zmdhNrJ67&&tZK1n?cf1=JiYJvxCKHJGfX)Ck8 zW`cZLa!#Lz1}S|?s2##X19OKQ_fr@I1VYT;$z0zWtBx ^$7Fl({ zcjUGq!z(|_HOSY>+C^yTGb3UyVaO(mr0c+h8l+-&XDWHM{sSpd+5NqrtGjv+?{$NO z@N12}zWQB0dKItx2He&+Ep%xDy?9wm@mGK@7paw_@$h}N^e}Y`$>v_; X~?gRD-us%@ydeA z7hXVb^J3nn6~Q!|HXG;dEdg#%8jcGIZWvuwL}@R!R%V-DF-+Z>En%=_$Q^8~o*#ys ziLxmS%)qs&tj)PIdEFzO_i5u_R&$r_(ry(o!|N@xhBaIUI*uS5`Ry2MkSF}mg%Ux6 zNX~{n^__Xcm|ycUfsg`E&hdTJ78a`7R;4s~M~SM}jDhS8=bE>I3SMwj9E~0bFjAtZ zsWr1s1Ydt+*hv*8LVt6998;#|N@9tbOv{vQ8qV~ilJV{gEX`&(WllbEaE)zYt46CY zL{=nf_lRl7nbfE5@( 6&X2pMPiz5f%Yu)q%;x1X zQ1>K_cEZ*Wrou;0D-K#eVva~>+O*R$P~wCx1@bJc7nNz%z!@x3tFH2)X)4G%_mTcI zq(^p9BgqsjDQRV8RP+jt-D$s_!h%+9Tyw?Zr0hQDr9i?*WDx8trB&|V%g17#U5BkK zg|?}4C&h@!ki4Ol8NdDM1yZsj1zQ$0cO!lSg#F$e=Uvcqfz+kCvID0>@%>Er11~DI zBq1^52WE(~OD+9eH(c;5N1gg*?=*i>R?IcwKo(OhZ^>@w_pkvw<`ssXqxb20DI5oH z#6bf?Zx-%EZmU=bPE$vxLCdSVou^8(m5OJ@ZQROgV6~y5Ch(Vt76S+5PraKSpv*!i zlx^Sr0UpiZAFQnyq62*eKcWZHlx9>a*?u}$YEp4Y6T3b?zOGSxhLdzP!-Oi}X|jGc zPj$*jW3&DH8FF}01@o!mC;Gx^2KQbouRkCYpX*h5Ep# hbl z7BwxA&J%>h-F3iq;w7R$t6G#xvZeiP#4Ds*zGJBoD_N2SXCtX^%HMgTvl85smWv$p z=^75(?(%22dIsim4&NnR{AeT6%z$~IM{+LT#AJvUe5eQz=AmQDR745LDLI!z2)VAq zJM%6`R@vZ7R@lw7!6CLY`)p&i8{#(^X#^r3OF}I9*YciyQSw=xJSD0>49UfhMM G~0W%V=ZMA zDpD+-TtFTDgZ&L3OT?KBuq+l{n!@B6Ufe8TtJw1R5--`Lw$;Vt =~hoi&mFwd@g%FcPoD>IQG6L@#E*- znfGm^u&%wR!bB{jG($xrjg7 *@8~GIGP+#}QFIj|T^;RP zPO95`S*3n!D`txN_!&H~aX9`d#~DNyZIiKGYM&{wiwvA=eUXYkC*&!$ZtOWlWR0dO z))ireu#$`>01Be*fo(WHQ*ySJl-i!cMlSu2#?uiYnLSpwmM7i>DLkM??ThJ&Z12hK z=v~D5mtMA07c^hFqcQwQ ?URrL8>F`pRq@R6T6}(3zZ;dr%YmP0n61ckGsh zSD8dEiEGVd>QYUa0J!6-NIQ{9uAFPOtqo&M$G&6Z{ITWElUQcg+2zgr@JJddg)hxf zTCSGCPS_Yam%#h4Jp*HuOKI7ZHNvbI99jVg_h~=lR>3iJr=;~qDfl-k-v+{9jO#7t z1gEtJF*Lr^B$tjyGK_8_O?k0AS@Q#ls1J|5C*yag z^;@SLa+}(}&|$yRwhLmYW2K&9sIi2s=wcFtTg0x_5<2s4#A4s030h_{VqaB92bX7| z98D{1`ks)m$ oHrmEVZ+G+b+u_$-NH&!u%B8ta61K%DmK`kKW%tU1P|58+ z*Mg{vpxiQO<}X^5Uku=|7nx rbWP=;}h)g&-oxpL3{!FSP~2MqwI* z;V5+P2*ZL@IgY*MUigqg>F`mxSow|mXAmtwhH?}qt$PQSCeMvXgmDz<_YRa5CGmA^ zx@6X2=-!Ew+JMO43Ek)F#W#GK{IU^3hRuyjrsC9` tJN< 9)Xz|g4GvUYFE;Ab@Lm&fIYYXZ4(yQA z-;HOHHJ$n(_F+nCf(8-OSEX0#1*02|4d&yTg9
X5#*uD{PQ#6uE8Y2dC~k_g z^95G4DEzG-q0~9N_t@O3@fyK?M9Z xXA{`CK;W(s5;0(0-QDKy%@Z>7%UX zD`R!2oCc#08<#H#AuJ(>`|r6&!k63q8^3GuR|I(XT6)cbThwAl#(P%29RaBY96`?0 z*;bxQn+4?tO`)$jS5qt)9z8Ci8ShcO5q|VC;A&lY_g4AiJ=+I0_7$Y}^Q7hZ0*#wB z*w=;0S*h?tD)ieQ-Svt|S|#=bREhqJzFSx*IPkM6ZP$U1o(DV*L56s8;3yKlaGpPW zpR8F!U0%*NvPNt0y2YX3GP+f rt z7oT=Fz}WANYqWfDT#Mk;>8tNi)mePYYRg}7NfP@+Pd1$PjG ;4jzt 5{%JtcO9_v~-Z=kHyhW~{!x0vy!^g`;Zm 77&=?v=Q!Rmqu!_ z_O>SHsqc~KNZI^2PyT>i1b*1;6-+xBOO9p~vgQz)ZqWYLtwLNdk^3>_DBwihyV@n} zUvbfPNta+u=#+ZuK%?T)4>;BiiVomzl}ri0D@%Wht*AUB`>4OSnt$oyHxINffzE9Z tf)GQhT=WQyzrAV^b^EKGysphp2HBe2gOwU?>60H`IvUs1U#i-K{~sM09Nz!{ literal 0 HcmV?d00001 diff --git a/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/Contents.json b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/Contents.json new file mode 100644 index 0000000000..b0e2cd5ebb --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/icon-transparent.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "60.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "120.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "180.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 7db9e88d3a..df724947c3 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -26,6 +26,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse configuration.includesCallsInRecents = UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) configuration.maximumCallGroups = 1 configuration.maximumCallsPerCallGroup = 1 + configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() return configuration }()) private let controller = CXCallController() From f48cabcc0a55602a5889b40e6f1601a382e8331f Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 14 Mar 2023 18:28:34 +0300 Subject: [PATCH 03/12] ios: CallKit double call in background fix (#2004) --- apps/ios/Shared/Views/Call/CallController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index df724947c3..cfeefdb903 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -139,6 +139,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse if type == .voIP { if (!ChatModel.shared.chatInitialized) { initChatAndMigrate() + startChatAndActivate() CallController.shared.onEndCall = { terminateChat() } // CallKit will be called from different place, see SimpleXAPI.startChat() return From 0404b020e6fb36e114c0e86b17a3d09d0e37e5bc Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 15 Mar 2023 13:21:21 +0300 Subject: [PATCH 04/12] ios: CallKit integrated with app lock and screen protect (#2007) * ios: CallKit integrated with app lock and screen protect * better lock mechanics * background color * logs * refactor, revert auth changes * additional state variable to allow connecting call * fix lock screen, public logs * show callkit option without dev tools --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/ContentView.swift | 59 +++++++++++-------- apps/ios/Shared/SimpleXApp.swift | 9 ++- .../Shared/Views/Call/ActiveCallView.swift | 25 ++++++-- .../Shared/Views/Call/CallController.swift | 28 +++++++-- apps/ios/Shared/Views/Call/WebRTCClient.swift | 7 +++ .../Views/UserSettings/CallSettings.swift | 16 ++--- 6 files changed, 102 insertions(+), 42 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2baf365c25..88b2758726 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -13,8 +13,10 @@ struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared + @Environment(\.colorScheme) var colorScheme @Binding var doAuthenticate: Bool @Binding var userAuthorized: Bool? + @Binding var canConnectCall: Bool @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -24,22 +26,29 @@ struct ContentView: View { var body: some View { ZStack { + if chatModel.showCallView, let call = chatModel.activeCall { + ActiveCallView(call: call, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) + } if prefPerformLA && userAuthorized != true { + Rectangle().fill(colorScheme == .dark ? .black : .white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture(perform: {}) Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } } else if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) } else if !chatModel.v3DBMigration.startChat { MigrateToAppGroupView() - } else if let step = chatModel.onboardingStage { + } else if let step = chatModel.onboardingStage, (!chatModel.showCallView || chatModel.activeCall == nil) { if case .onboardingComplete = step, chatModel.currentUser != nil { - mainView().privacySensitive(protectScreen) + mainView() } else { OnboardingView(onboarding: step) } } } .onAppear { + logger.debug("ContentView: canConnectCall \(canConnectCall), doAuthenticate \(doAuthenticate)") if doAuthenticate { runAuthenticate() } } .onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } } @@ -48,7 +57,7 @@ struct ContentView: View { private func mainView() -> some View { ZStack(alignment: .top) { - ChatListView() + ChatListView().privacySensitive(protectScreen) .onAppear { NtfManager.shared.requestAuthorization( onDeny: { @@ -75,31 +84,31 @@ struct ContentView: View { .sheet(isPresented: $showWhatsNew) { WhatsNewView() } - if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call) - } IncomingCallView() } - .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) - .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) - .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) +// .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) +// .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) +// .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) } - private func processUserActivity(_ activity: NSUserActivity) { - let callToContact = { (contactId: ChatId?, mediaType: CallMediaType) in - if let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, - case let .direct(contact) = chatInfo { - CallController.shared.startCall(contact, mediaType) - } - } - if let intent = activity.interaction?.intent as? INStartCallIntent { - callToContact(intent.contacts?.first?.personHandle?.value, .audio) - } else if let intent = activity.interaction?.intent as? INStartAudioCallIntent { - callToContact(intent.contacts?.first?.personHandle?.value, .audio) - } else if let intent = activity.interaction?.intent as? INStartVideoCallIntent { - callToContact(intent.contacts?.first?.personHandle?.value, .video) - } - } +// private func processUserActivity(_ activity: NSUserActivity) { +// let intent = activity.interaction?.intent +// if let contacts = (intent as? INStartCallIntent)?.contacts { +// callToContact(contacts, .audio) +// } else if let contacts = (intent as? INStartAudioCallIntent)?.contacts { +// callToContact(contacts, .audio) +// } else if let contacts = (intent as? INStartVideoCallIntent)?.contacts { +// callToContact(contacts, .video) +// } +// } +// +// private func callToContact(_ contacts: [INPerson], _ mediaType: CallMediaType) { +// if let contactId = contacts.first?.personHandle?.value, +// let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, +// case let .direct(contact) = chatInfo { +// CallController.shared.startCall(contact, mediaType) +// } +// } private func runAuthenticate() { if !prefPerformLA { @@ -118,10 +127,12 @@ struct ContentView: View { switch (laResult) { case .success: userAuthorized = true + canConnectCall = true case .failed: break case .unavailable: userAuthorized = true + canConnectCall = true prefPerformLA = false AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index a05da1ddfe..ec216b8063 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -20,6 +20,7 @@ struct SimpleXApp: App { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var userAuthorized: Bool? @State private var doAuthenticate = false + @State private var canConnectCall = false @State private var enteredBackground: TimeInterval? = nil init() { @@ -34,7 +35,7 @@ struct SimpleXApp: App { var body: some Scene { return WindowGroup { - ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized) + ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") @@ -60,6 +61,7 @@ struct SimpleXApp: App { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false + canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.onEndCall = nil @@ -67,9 +69,12 @@ struct SimpleXApp: App { startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { updateChats() - updateCallInvitations() + if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { + updateCallInvitations() + } } doAuthenticate = authenticationExpired() + canConnectCall = !(doAuthenticate && prefPerformLA) default: break } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 1604ab9ade..9c8b256e00 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -12,7 +12,10 @@ import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel + @Environment(\.scenePhase) var scenePhase @ObservedObject var call: Call + @Binding var userAuthorized: Bool? + @Binding var canConnectCall: Bool @State private var client: WebRTCClient? = nil @State private var activeCall: WebRTCClient.Call? = nil @State private var localRendererAspectRatio: CGFloat? = nil @@ -36,12 +39,19 @@ struct ActiveCallView: View { } } .onAppear { - if client == nil { - client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) - sendCommandToClient() - } + logger.debug("ActiveCallView: appear client is nil \(client == nil), userAuthorized \(userAuthorized.debugDescription, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") + createWebRTCClient() + } + .onChange(of: userAuthorized) { _ in + logger.debug("ActiveCallView: userAuthorized changed to \(userAuthorized.debugDescription, privacy: .public)") + createWebRTCClient() + } + .onChange(of: canConnectCall) { _ in + logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)") + createWebRTCClient() } .onDisappear { + logger.debug("ActiveCallView: disappear") client?.endCall() } .onChange(of: m.callCommand) { _ in sendCommandToClient()} @@ -49,6 +59,13 @@ struct ActiveCallView: View { .preferredColorScheme(.dark) } + private func createWebRTCClient() { + if client == nil && ((userAuthorized == true && canConnectCall) || scenePhase == .background) { + client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) + sendCommandToClient() + } + } + private func sendCommandToClient() { if call == m.activeCall, m.activeCall != nil, diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index cfeefdb903..a2fbe90c4c 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -21,9 +21,9 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse private let provider = CXProvider(configuration: { let configuration = CXProviderConfiguration() - configuration.supportsVideo = false + configuration.supportsVideo = true configuration.supportedHandleTypes = [.generic] - configuration.includesCallsInRecents = UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + configuration.includesCallsInRecents = false // UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) configuration.maximumCallGroups = 1 configuration.maximumCallsPerCallGroup = 1 configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() @@ -98,6 +98,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { print("received", #function) + logger.debug("CallController: activating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = true do { @@ -113,10 +114,12 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { print("received", #function) + logger.debug("CallController: deactivating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = false do { try audioSession.setActive(false) + logger.debug("audioSession deactivated") } catch { print(error) logger.error("failed deactivating audio session") @@ -125,6 +128,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse // see `.onChange(of: scenePhase)` in SimpleXApp DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in if ChatModel.shared.activeCall == nil { + logger.debug("CallController: calling callback onEndCall which is \(self?.onEndCall == nil ? "nil" : "non-nil", privacy: .public)") self?.onEndCall?() } } @@ -136,14 +140,17 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)") if type == .voIP { if (!ChatModel.shared.chatInitialized) { + logger.debug("CallController: initializing chat and returning") initChatAndMigrate() startChatAndActivate() CallController.shared.onEndCall = { terminateChat() } // CallKit will be called from different place, see SimpleXAPI.startChat() return } else { + logger.debug("CallController: starting chat (already initialized)") startChatAndActivate() CallController.shared.onEndCall = { suspendChat() @@ -162,6 +169,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse callUpdate.remoteHandle = CXHandle(type: .generic, value: contactId) callUpdate.localizedCallerName = displayName callUpdate.hasVideo = media == CallMediaType.video.rawValue + logger.debug("CallController: reporting incoming call directly to CallKit") CallController.shared.provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in if error != nil { ChatModel.shared.callInvitations.removeValue(forKey: contactId) @@ -174,7 +182,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { - logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID))") + logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)") if CallController.useCallKit(), let uuid = invitation.callkitUUID { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.id) @@ -190,6 +198,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) { + logger.debug("CallController: reporting incoming call connected") if CallController.useCallKit() { // Fulfilling this action only after connect, otherwise there are no audio and mic on lockscreen fulfillOnConnect?.fulfill() @@ -198,12 +207,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { + logger.debug("CallController: reporting outgoing call connected") if CallController.useCallKit(), let uuid = call.callkitUUID { provider.reportOutgoingCall(with: uuid, connectedAt: dateConnected) } } func reportCallRemoteEnded(invitation: RcvCallInvitation) { + logger.debug("CallController: reporting remote ended") if CallController.useCallKit(), let uuid = invitation.callkitUUID { provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) } else if invitation.contact.id == activeCallInvitation?.contact.id { @@ -212,6 +223,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func reportCallRemoteEnded(call: Call) { + logger.debug("CallController: reporting remote ended") if CallController.useCallKit(), let uuid = call.callkitUUID { provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) } @@ -241,6 +253,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func answerCall(invitation: RcvCallInvitation) { + logger.debug("CallController: answering a call") if CallController.useCallKit(), let callUUID = invitation.callkitUUID { requestTransaction(with: CXAnswerCallAction(call: callUUID)) } else { @@ -252,6 +265,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(callUUID: UUID) { + logger.debug("CallController: ending the call") if CallController.useCallKit() { requestTransaction(with: CXEndCallAction(call: callUUID)) } else { @@ -266,6 +280,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(invitation: RcvCallInvitation) { + logger.debug("CallController: ending the call") callManager.endCall(invitation: invitation) { if invitation.contact.id == self.activeCallInvitation?.contact.id { DispatchQueue.main.async { @@ -276,6 +291,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(call: Call, completed: @escaping () -> Void) { + logger.debug("CallController: ending the call") callManager.endCall(call: call, completed: completed) } @@ -292,10 +308,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse provider.configuration = conf } + func hasActiveCalls() -> Bool { + controller.callObserver.calls.count > 0 + } + private func requestTransaction(with action: CXAction, onSuccess: @escaping () -> Void = {}) { controller.request(CXTransaction(action: action)) { error in if let error = error { - logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription)") + logger.error("CallController.requestTransaction error requesting transaction: \(error.localizedDescription, privacy: .public)") } else { logger.debug("CallController.requestTransaction requested transaction successfully") onSuccess() diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 7118d04a79..f64276f9b3 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -49,6 +49,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg self.localRendererAspectRatio = localRendererAspectRatio rtcAudioSession.useManualAudio = CallController.useCallKit() rtcAudioSession.isAudioEnabled = !CallController.useCallKit() + logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)}") super.init() } @@ -241,6 +242,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } func enableMedia(_ media: CallMediaType, _ enable: Bool) { + logger.debug("WebRTCClient: enabling media \(media.rawValue) \(enable)") media == .video ? setVideoEnabled(enable) : setAudioEnabled(enable) } @@ -363,6 +365,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg func endCall() { guard let call = activeCall.wrappedValue else { return } + logger.debug("WebRTCClient: ending the call") activeCall.wrappedValue = nil call.connection.close() call.connection.delegate = nil @@ -534,6 +537,7 @@ extension WebRTCClient { } func setSpeakerEnabledAndConfigureSession( _ enabled: Bool) { + logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled)") audioQueue.async { [weak self] in guard let self = self else { return } self.rtcAudioSession.lockForConfiguration() @@ -545,6 +549,7 @@ extension WebRTCClient { try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue) try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none) try self.rtcAudioSession.setActive(true) + logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success") } catch let error { logger.debug("Error configuring AVAudioSession: \(error)") } @@ -552,6 +557,7 @@ extension WebRTCClient { } func audioSessionToDefaults() { + logger.debug("WebRTCClient: audioSession to defaults") audioQueue.async { [weak self] in guard let self = self else { return } self.rtcAudioSession.lockForConfiguration() @@ -563,6 +569,7 @@ extension WebRTCClient { try self.rtcAudioSession.setMode(AVAudioSession.Mode.default.rawValue) try self.rtcAudioSession.overrideOutputAudioPort(.none) try self.rtcAudioSession.setActive(false) + logger.debug("WebRTCClient: audioSession to defaults success") } catch let error { logger.debug("Error configuring AVAudioSession with defaults: \(error)") } diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index cfc18011bb..9d3f56c710 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -22,16 +22,16 @@ struct CallSettings: View { Section { Toggle("Connect via relay", isOn: $webrtcPolicyRelay) - if !CallController.isInChina && developerTools { + if !CallController.isInChina { Toggle("Use CallKit", isOn: $callKitEnabled) - if allowChangingCallsHistory { - Toggle("Show calls in phone history", isOn: $callKitCallsInRecents) - .disabled(!callKitEnabled) - .onChange(of: callKitCallsInRecents) { value in - CallController.shared.showInRecents(value) - } - } +// if allowChangingCallsHistory { +// Toggle("Show calls in phone history", isOn: $callKitCallsInRecents) +// .disabled(!callKitEnabled) +// .onChange(of: callKitCallsInRecents) { value in +// CallController.shared.showInRecents(value) +// } +// } } NavigationLink { From 840df89ca631eddb8c954fb89d7c54671747f3ef Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:32:27 +0300 Subject: [PATCH 05/12] ios: CallKit enhancements (#2010) * ios: CallKit enhancements * better checks --- apps/ios/Shared/AppDelegate.swift | 10 ++++++++ apps/ios/Shared/ContentView.swift | 4 ++- apps/ios/Shared/Model/ChatModel.swift | 5 ++-- apps/ios/Shared/SimpleXApp.swift | 15 +++++++++-- .../Shared/Views/Call/ActiveCallView.swift | 25 +++++++++++++------ 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index f20882383a..457aaa2824 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -94,6 +94,16 @@ class AppDelegate: NSObject, UIApplicationDelegate { return configuration } + func applicationProtectedDataWillBecomeUnavailable(_ application: UIApplication) { + logger.debug("AppDelegate: will lock screen") + ChatModel.shared.onLockScreenCurrently = true + } + + func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { + logger.debug("AppDelegate: did unlock screen") + ChatModel.shared.onLockScreenCurrently = false + } + private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let complete = BGManager.shared.completionHandler { logger.debug("AppDelegate: completed BGManager.receiveMessages") diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 88b2758726..2b56580136 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -17,6 +17,7 @@ struct ContentView: View { @Binding var doAuthenticate: Bool @Binding var userAuthorized: Bool? @Binding var canConnectCall: Bool + @Binding var lastSuccessfulUnlock: TimeInterval? @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -27,7 +28,7 @@ struct ContentView: View { var body: some View { ZStack { if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) + ActiveCallView(call: call, canConnectCall: $canConnectCall) } if prefPerformLA && userAuthorized != true { Rectangle().fill(colorScheme == .dark ? .black : .white) @@ -128,6 +129,7 @@ struct ContentView: View { case .success: userAuthorized = true canConnectCall = true + lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime case .failed: break case .unavailable: diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index ac00f62689..11d7bce826 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -9,7 +9,6 @@ import Foundation import Combine import SwiftUI -import WebKit import SimpleXChat final class ChatModel: ObservableObject { @@ -59,7 +58,9 @@ final class ChatModel: ObservableObject { @Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches @Published var draft: ComposeState? @Published var draftChatId: String? - var callWebView: WKWebView? + + var sceneWasActiveAtLeastOnce = false + var onLockScreenCurrently = false var messageDelivery: Dictionary Void> = [:] diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index ec216b8063..1e25e15426 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -22,6 +22,7 @@ struct SimpleXApp: App { @State private var doAuthenticate = false @State private var canConnectCall = false @State private var enteredBackground: TimeInterval? = nil + @State private var lastSuccessfulUnlock: TimeInterval? = nil init() { hs_init(0, nil) @@ -35,7 +36,7 @@ struct SimpleXApp: App { var body: some Scene { return WindowGroup { - ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall) + ContentView(doAuthenticate: $doAuthenticate, userAuthorized: $userAuthorized, canConnectCall: $canConnectCall, lastSuccessfulUnlock: $lastSuccessfulUnlock) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") @@ -65,6 +66,7 @@ struct SimpleXApp: App { NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.onEndCall = nil + chatModel.sceneWasActiveAtLeastOnce = true let appState = appStateGroupDefault.get() startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { @@ -74,7 +76,7 @@ struct SimpleXApp: App { } } doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) + canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() default: break } @@ -114,6 +116,15 @@ struct SimpleXApp: App { } } + + private func unlockedRecently() -> Bool { + if let lastSuccessfulUnlock = lastSuccessfulUnlock { + return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 + } else { + return false + } + } + private func updateChats() { do { let chats = try apiGetChats() diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 9c8b256e00..98a006ae92 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -14,7 +14,6 @@ struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @Environment(\.scenePhase) var scenePhase @ObservedObject var call: Call - @Binding var userAuthorized: Bool? @Binding var canConnectCall: Bool @State private var client: WebRTCClient? = nil @State private var activeCall: WebRTCClient.Call? = nil @@ -39,11 +38,7 @@ struct ActiveCallView: View { } } .onAppear { - logger.debug("ActiveCallView: appear client is nil \(client == nil), userAuthorized \(userAuthorized.debugDescription, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") - createWebRTCClient() - } - .onChange(of: userAuthorized) { _ in - logger.debug("ActiveCallView: userAuthorized changed to \(userAuthorized.debugDescription, privacy: .public)") + logger.debug("ActiveCallView: appear client is nil \(client == nil), canConnectCall \(canConnectCall, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") createWebRTCClient() } .onChange(of: canConnectCall) { _ in @@ -60,8 +55,22 @@ struct ActiveCallView: View { } private func createWebRTCClient() { - if client == nil && ((userAuthorized == true && canConnectCall) || scenePhase == .background) { - client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) + if client == nil && (canConnectCall || m.onLockScreenCurrently) { + createWebRTCClientWithoutWait() + } else if (!m.sceneWasActiveAtLeastOnce) { + // This code waits a second until it recheck `sceneWasActiveAtLeastOnce`. + // It helps to know whether a call from lockscreen or not. + // After the second `sceneWasActiveAtLeastOnce` will still be false when the call from lockscreen + Task { + try? await Task.sleep(nanoseconds: 1_000_000_000) + createWebRTCClientWithoutWait() + } + } + } + + private func createWebRTCClientWithoutWait() { + if client == nil && (canConnectCall || !m.sceneWasActiveAtLeastOnce || m.onLockScreenCurrently) { + client = WebRTCClient($activeCall, { msg in await MainActor.run {processRtcMessage(msg: msg)} }, $localRendererAspectRatio) sendCommandToClient() } } From 2643ea90660bfea4caef18d9b62f72181e485119 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 16 Mar 2023 00:09:33 +0300 Subject: [PATCH 06/12] ios: reverted some changes related to lockScreen (#2011) * Revert "ios: CallKit enhancements (#2010)" This reverts commit 840df89ca631eddb8c954fb89d7c54671747f3ef. * Revert "ios: CallKit integrated with app lock and screen protect (#2007)" This reverts commit 0404b020e6fb36e114c0e86b17a3d09d0e37e5bc. * ios: reverted some changes related to lockScreen * undo delay * better support of appLock + call * refactor * refactor 2 * refactor 3 * refactor 4 --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/AppDelegate.swift | 10 -- apps/ios/Shared/ContentView.swift | 125 +++++++++++------- apps/ios/Shared/Model/ChatModel.swift | 3 - apps/ios/Shared/SimpleXApp.swift | 4 +- .../Shared/Views/Call/ActiveCallView.swift | 24 +--- .../Shared/Views/Call/CallController.swift | 8 +- 6 files changed, 88 insertions(+), 86 deletions(-) diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 457aaa2824..f20882383a 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -94,16 +94,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { return configuration } - func applicationProtectedDataWillBecomeUnavailable(_ application: UIApplication) { - logger.debug("AppDelegate: will lock screen") - ChatModel.shared.onLockScreenCurrently = true - } - - func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { - logger.debug("AppDelegate: did unlock screen") - ChatModel.shared.onLockScreenCurrently = false - } - private func receiveMessages(_ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let complete = BGManager.shared.completionHandler { logger.debug("AppDelegate: completed BGManager.receiveMessages") diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2b56580136..2bfec8368d 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -27,48 +27,64 @@ struct ContentView: View { var body: some View { ZStack { + contentView() if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call, canConnectCall: $canConnectCall) - } - if prefPerformLA && userAuthorized != true { - Rectangle().fill(colorScheme == .dark ? .black : .white) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(perform: {}) - Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } - } else if let status = chatModel.chatDbStatus, status != .ok { - DatabaseErrorView(status: status) - } else if !chatModel.v3DBMigration.startChat { - MigrateToAppGroupView() - } else if let step = chatModel.onboardingStage, (!chatModel.showCallView || chatModel.activeCall == nil) { - if case .onboardingComplete = step, - chatModel.currentUser != nil { - mainView() - } else { - OnboardingView(onboarding: step) - } + callView(call) } } .onAppear { - logger.debug("ContentView: canConnectCall \(canConnectCall), doAuthenticate \(doAuthenticate)") - if doAuthenticate { runAuthenticate() } + if prefPerformLA { requestNtfAuthorization() } + initAuthenticate() + } + .onChange(of: doAuthenticate) { _ in + initAuthenticate() } - .onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } + @ViewBuilder private func contentView() -> some View { + if prefPerformLA && userAuthorized != true { + lockButton() + } else if let status = chatModel.chatDbStatus, status != .ok { + DatabaseErrorView(status: status) + } else if !chatModel.v3DBMigration.startChat { + MigrateToAppGroupView() + } else if let step = chatModel.onboardingStage { + if case .onboardingComplete = step, + chatModel.currentUser != nil { + mainView() + } else { + OnboardingView(onboarding: step) + } + } + } + + @ViewBuilder private func callView(_ call: Call) -> some View { + if CallController.useCallKit() { + ActiveCallView(call: call, canConnectCall: Binding.constant(true)) + .onDisappear { + if userAuthorized == false && doAuthenticate { runAuthenticate() } + } + } else { + ActiveCallView(call: call, canConnectCall: $canConnectCall) + if prefPerformLA && userAuthorized != true { + Rectangle() + .fill(colorScheme == .dark ? .black : .white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + lockButton() + } + } + } + + private func lockButton() -> some View { + Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } + } + private func mainView() -> some View { ZStack(alignment: .top) { ChatListView().privacySensitive(protectScreen) .onAppear { - NtfManager.shared.requestAuthorization( - onDeny: { - if (!notificationAlertShown) { - notificationAlertShown = true - alertManager.showAlert(notificationAlert()) - } - }, - onAuthorized: { notificationAlertShown = false } - ) + if !prefPerformLA { requestNtfAuthorization() } // Local Authentication notice is to be shown on next start after onboarding is complete if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) { prefLANoticeShown = true @@ -93,24 +109,29 @@ struct ContentView: View { } // private func processUserActivity(_ activity: NSUserActivity) { -// let intent = activity.interaction?.intent -// if let contacts = (intent as? INStartCallIntent)?.contacts { -// callToContact(contacts, .audio) -// } else if let contacts = (intent as? INStartAudioCallIntent)?.contacts { -// callToContact(contacts, .audio) -// } else if let contacts = (intent as? INStartVideoCallIntent)?.contacts { -// callToContact(contacts, .video) -// } -// } -// -// private func callToContact(_ contacts: [INPerson], _ mediaType: CallMediaType) { -// if let contactId = contacts.first?.personHandle?.value, -// let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, -// case let .direct(contact) = chatInfo { -// CallController.shared.startCall(contact, mediaType) +// let callToContact = { (contactId: ChatId?, mediaType: CallMediaType) in +// if let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, +// case let .direct(contact) = chatInfo { +// CallController.shared.startCall(contact, mediaType) +// } +// } +// if let intent = activity.interaction?.intent as? INStartCallIntent { +// callToContact(intent.contacts?.first?.personHandle?.value, .audio) +// } else if let intent = activity.interaction?.intent as? INStartAudioCallIntent { +// callToContact(intent.contacts?.first?.personHandle?.value, .audio) +// } else if let intent = activity.interaction?.intent as? INStartVideoCallIntent { +// callToContact(intent.contacts?.first?.personHandle?.value, .video) // } // } + private func initAuthenticate() { + if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { + userAuthorized = false + } else if doAuthenticate { + runAuthenticate() + } + } + private func runAuthenticate() { if !prefPerformLA { userAuthorized = true @@ -134,13 +155,25 @@ struct ContentView: View { break case .unavailable: userAuthorized = true - canConnectCall = true prefPerformLA = false + canConnectCall = true AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } } } + func requestNtfAuthorization() { + NtfManager.shared.requestAuthorization( + onDeny: { + if (!notificationAlertShown) { + notificationAlertShown = true + alertManager.showAlert(notificationAlert()) + } + }, + onAuthorized: { notificationAlertShown = false } + ) + } + func laNoticeAlert() -> Alert { Alert( title: Text("SimpleX Lock"), diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 11d7bce826..9100f7bcb9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -59,9 +59,6 @@ final class ChatModel: ObservableObject { @Published var draft: ComposeState? @Published var draftChatId: String? - var sceneWasActiveAtLeastOnce = false - var onLockScreenCurrently = false - var messageDelivery: Dictionary Void> = [:] var filesToDelete: [String] = [] diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 1e25e15426..08fca3af05 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -20,8 +20,8 @@ struct SimpleXApp: App { @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @State private var userAuthorized: Bool? @State private var doAuthenticate = false - @State private var canConnectCall = false @State private var enteredBackground: TimeInterval? = nil + @State private var canConnectCall = false @State private var lastSuccessfulUnlock: TimeInterval? = nil init() { @@ -66,7 +66,6 @@ struct SimpleXApp: App { NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.onEndCall = nil - chatModel.sceneWasActiveAtLeastOnce = true let appState = appStateGroupDefault.get() startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { @@ -116,7 +115,6 @@ struct SimpleXApp: App { } } - private func unlockedRecently() -> Bool { if let lastSuccessfulUnlock = lastSuccessfulUnlock { return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 98a006ae92..e4a9385706 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -12,12 +12,12 @@ import SimpleXChat struct ActiveCallView: View { @EnvironmentObject var m: ChatModel - @Environment(\.scenePhase) var scenePhase @ObservedObject var call: Call - @Binding var canConnectCall: Bool + @Environment(\.scenePhase) var scenePhase @State private var client: WebRTCClient? = nil @State private var activeCall: WebRTCClient.Call? = nil @State private var localRendererAspectRatio: CGFloat? = nil + @Binding var canConnectCall: Bool var body: some View { ZStack(alignment: .bottom) { @@ -38,7 +38,7 @@ struct ActiveCallView: View { } } .onAppear { - logger.debug("ActiveCallView: appear client is nil \(client == nil), canConnectCall \(canConnectCall, privacy: .public), scenePhase \(String(describing: scenePhase), privacy: .public)") + logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") createWebRTCClient() } .onChange(of: canConnectCall) { _ in @@ -55,22 +55,8 @@ struct ActiveCallView: View { } private func createWebRTCClient() { - if client == nil && (canConnectCall || m.onLockScreenCurrently) { - createWebRTCClientWithoutWait() - } else if (!m.sceneWasActiveAtLeastOnce) { - // This code waits a second until it recheck `sceneWasActiveAtLeastOnce`. - // It helps to know whether a call from lockscreen or not. - // After the second `sceneWasActiveAtLeastOnce` will still be false when the call from lockscreen - Task { - try? await Task.sleep(nanoseconds: 1_000_000_000) - createWebRTCClientWithoutWait() - } - } - } - - private func createWebRTCClientWithoutWait() { - if client == nil && (canConnectCall || !m.sceneWasActiveAtLeastOnce || m.onLockScreenCurrently) { - client = WebRTCClient($activeCall, { msg in await MainActor.run {processRtcMessage(msg: msg)} }, $localRendererAspectRatio) + if client == nil && canConnectCall { + client = WebRTCClient($activeCall, { msg in await MainActor.run { processRtcMessage(msg: msg) } }, $localRendererAspectRatio) sendCommandToClient() } } diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index a2fbe90c4c..7ad756b1c0 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -97,7 +97,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { - print("received", #function) logger.debug("CallController: activating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = true @@ -113,7 +112,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { - print("received", #function) logger.debug("CallController: deactivating audioSession and audio in WebRTCClient") RTCAudioSession.sharedInstance().audioSessionDidDeactivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = false @@ -265,7 +263,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(callUUID: UUID) { - logger.debug("CallController: ending the call") + logger.debug("CallController: ending the call with UUID \(callUUID.uuidString)") if CallController.useCallKit() { requestTransaction(with: CXEndCallAction(call: callUUID)) } else { @@ -280,7 +278,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(invitation: RcvCallInvitation) { - logger.debug("CallController: ending the call") + logger.debug("CallController: ending the call with invitation") callManager.endCall(invitation: invitation) { if invitation.contact.id == self.activeCallInvitation?.contact.id { DispatchQueue.main.async { @@ -291,7 +289,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func endCall(call: Call, completed: @escaping () -> Void) { - logger.debug("CallController: ending the call") + logger.debug("CallController: ending the call with call instance") callManager.endCall(call: call, completed: completed) } From 809cc1f234f35b2b231e35e426877b51ca657c66 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 08:46:13 +0000 Subject: [PATCH 07/12] ios: different speaker buttons on call screen --- apps/ios/Shared/Views/Call/ActiveCallView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index e4a9385706..83b3bd5ae3 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -264,7 +264,7 @@ struct ActiveCallOverlay: View { private func endCallButton() -> some View { let cc = CallController.shared - return callButton("phone.down.fill", size: 60) { + return callButton("phone.down.fill", width: 60, height: 60) { if let uuid = call.callkitUUID { cc.endCall(callUUID: uuid) } else { @@ -286,7 +286,7 @@ struct ActiveCallOverlay: View { } private func toggleSpeakerButton() -> some View { - controlButton(call, call.speakerEnabled ? "speaker.fill" : "speaker.slash") { + controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") { Task { client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled) DispatchQueue.main.async { @@ -317,22 +317,22 @@ struct ActiveCallOverlay: View { @ViewBuilder private func controlButton(_ call: Call, _ imageName: String, _ perform: @escaping () -> Void) -> some View { if call.hasMedia { - callButton(imageName, size: 40, perform) + callButton(imageName, width: 50, height: 38, perform) .foregroundColor(.white) .opacity(0.85) } else { - Color.clear.frame(width: 40, height: 40) + Color.clear.frame(width: 50, height: 38) } } - private func callButton(_ imageName: String, size: CGFloat, _ perform: @escaping () -> Void) -> some View { + private func callButton(_ imageName: String, width: CGFloat, height: CGFloat, _ perform: @escaping () -> Void) -> some View { Button { perform() } label: { Image(systemName: imageName) .resizable() .scaledToFit() - .frame(maxWidth: size, maxHeight: size) + .frame(maxWidth: width, maxHeight: height) } } } From 6724de09c9fa5f50b8f5d96ff74f8357cc340461 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 16:59:05 +0000 Subject: [PATCH 08/12] ios: dismiss sheets on IncomingCallView, send notification if reportNewIncomingVoIPPushPayload fails --- .../Shared/Views/Call/CallController.swift | 3 ++- .../Shared/Views/Call/IncomingCallView.swift | 1 + .../ios/SimpleX NSE/NotificationService.swift | 21 +++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 7ad756b1c0..2b5682b9c8 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -46,6 +46,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } func providerDidReset(_ provider: CXProvider) { + logger.debug("CallController.providerDidReset") } func provider(_ provider: CXProvider, perform action: CXStartCallAction) { @@ -134,7 +135,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse @objc(pushRegistry:didUpdatePushCredentials:forType:) func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { - + logger.debug("CallController: didUpdate push credentials for type \(type.rawValue, privacy: .public)") } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift index 0044434efd..c2d5dabd48 100644 --- a/apps/ios/Shared/Views/Call/IncomingCallView.swift +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -65,6 +65,7 @@ struct IncomingCallView: View { .padding(.vertical, 12) .frame(maxWidth: .infinity) .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .onAppear { dismissAllSheets() } } private func callButton(_ text: LocalizedStringKey, _ image: String, _ color: Color, action: @escaping () -> Void) -> some View { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 701cfd9433..d338774f5c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -244,18 +244,17 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification case let .callInvitation(invitation): // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit if useCallKit() { - CXProvider.reportNewIncomingVoIPPushPayload([ - "displayName": invitation.contact.displayName, - "contactId": invitation.contact.id, - "media": invitation.callType.media.rawValue - ]) { error in - if let error = error { - logger.error("reportNewIncomingVoIPPushPayload error \(error.localizedDescription, privacy: .public)") - } else { - logger.debug("reportNewIncomingVoIPPushPayload success for \(invitation.contact.id)") - } + do { + try await CXProvider.reportNewIncomingVoIPPushPayload([ + "displayName": invitation.contact.displayName, + "contactId": invitation.contact.id, + "media": invitation.callType.media.rawValue + ]) + logger.debug("reportNewIncomingVoIPPushPayload success for \(invitation.contact.id)") + return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) + } catch let error { + logger.error("reportNewIncomingVoIPPushPayload error \(String(describing: error), privacy: .public)") } - return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) } return (invitation.contact.id, createCallInvitationNtf(invitation)) default: From 063440e73523d4902b3829d48af3db66f23fb05f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 17:18:25 +0000 Subject: [PATCH 09/12] ios: remove sheets in ActiveCallView (does not work when call accepted from background via callkit) --- apps/ios/Shared/Views/Call/ActiveCallView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 83b3bd5ae3..393a370eed 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -40,6 +40,7 @@ struct ActiveCallView: View { .onAppear { logger.debug("ActiveCallView: appear client is nil \(client == nil), scenePhase \(String(describing: scenePhase), privacy: .public), canConnectCall \(canConnectCall)") createWebRTCClient() + dismissAllSheets() } .onChange(of: canConnectCall) { _ in logger.debug("ActiveCallView: canConnectCall changed to \(canConnectCall, privacy: .public)") From 8145387f77d06a4ca94963b6ea49637c4859a27c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 19:57:43 +0000 Subject: [PATCH 10/12] ios: CallKit changed reporting logic (#2019) * ios: CallKit changed reporting logic * refactor, suspend chat after call when app is in background --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 10 +- apps/ios/Shared/Model/SuspendChat.swift | 4 +- apps/ios/Shared/SimpleXApp.swift | 7 +- .../Shared/Views/Call/CallController.swift | 114 ++++++++++++------ .../ios/SimpleX NSE/NotificationService.swift | 2 +- 5 files changed, 86 insertions(+), 51 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a86c602c3b..f2bb6ba198 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -909,7 +909,7 @@ func apiGetVersion() throws -> CoreVersionInfo { throw r } -func initializeChat(start: Bool, dbKey: String? = nil) throws { +func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true) throws { logger.debug("initializeChat") let m = ChatModel.shared (m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey) @@ -925,13 +925,13 @@ func initializeChat(start: Bool, dbKey: String? = nil) throws { if m.currentUser == nil { m.onboardingStage = .step1_SimpleXInfo } else if start { - try startChat() + try startChat(refreshInvitations: refreshInvitations) } else { m.chatRunning = false } } -func startChat() throws { +func startChat(refreshInvitations: Bool = true) throws { logger.debug("startChat") let m = ChatModel.shared try setNetworkConfig(getNetCfg()) @@ -940,7 +940,9 @@ func startChat() throws { if justStarted { try getUserChatData() NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) - try refreshCallInvitations() + if (refreshInvitations) { + try refreshCallInvitations() + } (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() if let token = m.deviceToken { registerToken(token: token) diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 7804e2e826..6d8108a3e3 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -82,12 +82,12 @@ func activateChat(appState: AppState = .active) { } } -func initChatAndMigrate() { +func initChatAndMigrate(refreshInvitations: Bool = true) { let m = ChatModel.shared if (!m.chatInitialized) { do { m.v3DBMigration = v3DBMigrationDefault.get() - try initializeChat(start: m.v3DBMigration.startChat) + try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations) } catch let error { fatalError("Failed to start or load chats: \(responseError(error))") } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 08fca3af05..b93d402a89 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -50,10 +50,7 @@ struct SimpleXApp: App { switch (phase) { case .background: if CallController.useCallKit() && chatModel.activeCall != nil { - CallController.shared.onEndCall = { - suspendChat() - BGManager.shared.schedule() - } + CallController.shared.shouldSuspendChat = true } else { suspendChat() BGManager.shared.schedule() @@ -65,7 +62,7 @@ struct SimpleXApp: App { canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: - CallController.shared.onEndCall = nil + CallController.shared.shouldSuspendChat = false let appState = appStateGroupDefault.get() startChatAndActivate() if appState.inactive && chatModel.chatRunning == true { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 2b5682b9c8..365580735c 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -32,7 +32,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse private let controller = CXCallController() private let callManager = CallManager() @Published var activeCallInvitation: RcvCallInvitation? - var onEndCall: (() -> Void)? = nil + var shouldSuspendChat: Bool = false var fulfillOnConnect: CXAnswerCallAction? = nil // PKPushRegistry is used from notification service extension @@ -81,6 +81,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } else { action.fail() } + self.suspendOnEndCall() } } @@ -127,12 +128,20 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse // see `.onChange(of: scenePhase)` in SimpleXApp DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in if ChatModel.shared.activeCall == nil { - logger.debug("CallController: calling callback onEndCall which is \(self?.onEndCall == nil ? "nil" : "non-nil", privacy: .public)") - self?.onEndCall?() + logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)") + self?.suspendOnEndCall() } } } + func suspendOnEndCall() { + if shouldSuspendChat { + shouldSuspendChat = false + suspendChat() + BGManager.shared.schedule() + } + } + @objc(pushRegistry:didUpdatePushCredentials:forType:) func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { logger.debug("CallController: didUpdate push credentials for type \(type.rawValue, privacy: .public)") @@ -140,53 +149,72 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { logger.debug("CallController: did receive push with type \(type.rawValue, privacy: .public)") - if type == .voIP { - if (!ChatModel.shared.chatInitialized) { - logger.debug("CallController: initializing chat and returning") - initChatAndMigrate() - startChatAndActivate() - CallController.shared.onEndCall = { terminateChat() } - // CallKit will be called from different place, see SimpleXAPI.startChat() - return - } else { - logger.debug("CallController: starting chat (already initialized)") - startChatAndActivate() - CallController.shared.onEndCall = { - suspendChat() - BGManager.shared.schedule() - } - } - // No actual list of invitations in model before this line - let invitations = try? justRefreshCallInvitations() - logger.debug("Invitations \(String(describing: invitations))") - // Extract the call information from the push notification payload - if let displayName = payload.dictionaryPayload["displayName"] as? String, - let contactId = payload.dictionaryPayload["contactId"] as? String, - let uuid = ChatModel.shared.callInvitations.first(where: { (key, value) in value.contact.id == contactId } )?.value.callkitUUID, - let media = payload.dictionaryPayload["media"] as? String { - let callUpdate = CXCallUpdate() - callUpdate.remoteHandle = CXHandle(type: .generic, value: contactId) - callUpdate.localizedCallerName = displayName - callUpdate.hasVideo = media == CallMediaType.video.rawValue - logger.debug("CallController: reporting incoming call directly to CallKit") - CallController.shared.provider.reportNewIncomingCall(with: uuid, update: callUpdate, completion: { error in + if type != .voIP { + completion() + return + } + logger.debug("CallController: initializing chat") + if (!ChatModel.shared.chatInitialized) { + initChatAndMigrate(refreshInvitations: false) + } + startChatAndActivate() + shouldSuspendChat = true + // There are no invitations in the model, as it was processed by NSE + _ = try? justRefreshCallInvitations() + // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") + // Extract the call information from the push notification payload + let m = ChatModel.shared + if let contactId = payload.dictionaryPayload["contactId"] as? String, + let invitation = m.callInvitations[contactId] { + let update = cxCallUpdate(invitation: invitation) + if let uuid = invitation.callkitUUID { + logger.debug("CallController: report pushkit call via CallKit") + let update = cxCallUpdate(invitation: invitation) + provider.reportNewIncomingCall(with: uuid, update: update) { error in if error != nil { - ChatModel.shared.callInvitations.removeValue(forKey: contactId) + m.callInvitations.removeValue(forKey: contactId) } // Tell PushKit that the notification is handled. completion() - }) + } + } else { + reportExpiredCall(update: update, completion) } + } else { + reportExpiredCall(payload: payload, completion) } } + // This function fulfils the requirement to always report a call when PushKit notification is received, + // even when there is no more active calls by the time PushKit payload is processed. + // See the note in the bottom of this article: + // https://developer.apple.com/documentation/pushkit/pkpushregistrydelegate/2875784-pushregistry + private func reportExpiredCall(update: CXCallUpdate, _ completion: @escaping () -> Void) { + logger.debug("CallController: report expired pushkit call via CallKit") + let uuid = UUID() + provider.reportNewIncomingCall(with: uuid, update: update) { error in + if error == nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded) + } + } + completion() + } + } + + private func reportExpiredCall(payload: PKPushPayload, _ completion: @escaping () -> Void) { + let update = CXCallUpdate() + let displayName = payload.dictionaryPayload["displayName"] as? String + let media = payload.dictionaryPayload["media"] as? String + update.localizedCallerName = displayName ?? NSLocalizedString("Unknown caller", comment: "callkit banner") + update.hasVideo = media == CallMediaType.video.rawValue + reportExpiredCall(update: update, completion) + } + func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)") if CallController.useCallKit(), let uuid = invitation.callkitUUID { - let update = CXCallUpdate() - update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.id) - update.hasVideo = invitation.callType.media == .video - update.localizedCallerName = invitation.contact.displayName + let update = cxCallUpdate(invitation: invitation) provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) } else { NtfManager.shared.notifyCallInvitation(invitation) @@ -196,6 +224,14 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + private func cxCallUpdate(invitation: RcvCallInvitation) -> CXCallUpdate { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: invitation.contact.id) + update.hasVideo = invitation.callType.media == .video + update.localizedCallerName = invitation.contact.displayName + return update + } + func reportIncomingCall(call: Call, connectedAt dateConnected: Date?) { logger.debug("CallController: reporting incoming call connected") if CallController.useCallKit() { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index d338774f5c..d31a32e110 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -250,7 +250,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification "contactId": invitation.contact.id, "media": invitation.callType.media.rawValue ]) - logger.debug("reportNewIncomingVoIPPushPayload success for \(invitation.contact.id)") + logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) } catch let error { logger.error("reportNewIncomingVoIPPushPayload error \(String(describing: error), privacy: .public)") From 7a9f2202907bb1901debfe6921877c404336f8a3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 20:19:53 +0000 Subject: [PATCH 11/12] ios: do not suspend chat when switching to another callkit call (#2020) --- .../Shared/Views/Call/CallController.swift | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 365580735c..83338804c1 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -124,21 +124,21 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse print(error) logger.error("failed deactivating audio session") } - // Allows to accept second call while in call with a previous before suspending a chat, - // see `.onChange(of: scenePhase)` in SimpleXApp - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - if ChatModel.shared.activeCall == nil { - logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)") - self?.suspendOnEndCall() - } - } + suspendOnEndCall() } func suspendOnEndCall() { if shouldSuspendChat { - shouldSuspendChat = false - suspendChat() - BGManager.shared.schedule() + // The delay allows to accept the second call before suspending a chat + // see `.onChange(of: scenePhase)` in SimpleXApp + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + logger.debug("CallController: shouldSuspendChat \(String(describing: self?.shouldSuspendChat), privacy: .public)") + if ChatModel.shared.activeCall == nil && self?.shouldSuspendChat == true { + self?.shouldSuspendChat = false + suspendChat() + BGManager.shared.schedule() + } + } } } From 9db19242683f73505d42bbd4bfc94a35b2a2e2e8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:08:58 +0000 Subject: [PATCH 12/12] ios: optionally show callkit calls in recents and update settings (#2021) * ios: optionally show callkit calls in recents and update settings * refactor, fix call error when starting from recents --- apps/ios/Shared/ContentView.swift | 43 +++++++++++-------- .../Shared/Views/Call/CallController.swift | 2 +- .../Views/UserSettings/CallSettings.swift | 36 +++++++++------- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 2bfec8368d..aeccaf9346 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -103,26 +103,33 @@ struct ContentView: View { } IncomingCallView() } -// .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) -// .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) -// .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) + .onContinueUserActivity("INStartCallIntent", perform: processUserActivity) + .onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity) + .onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity) } -// private func processUserActivity(_ activity: NSUserActivity) { -// let callToContact = { (contactId: ChatId?, mediaType: CallMediaType) in -// if let chatInfo = chatModel.chats.first(where: { $0.id == contactId })?.chatInfo, -// case let .direct(contact) = chatInfo { -// CallController.shared.startCall(contact, mediaType) -// } -// } -// if let intent = activity.interaction?.intent as? INStartCallIntent { -// callToContact(intent.contacts?.first?.personHandle?.value, .audio) -// } else if let intent = activity.interaction?.intent as? INStartAudioCallIntent { -// callToContact(intent.contacts?.first?.personHandle?.value, .audio) -// } else if let intent = activity.interaction?.intent as? INStartVideoCallIntent { -// callToContact(intent.contacts?.first?.personHandle?.value, .video) -// } -// } + private func processUserActivity(_ activity: NSUserActivity) { + let intent = activity.interaction?.intent + if let intent = intent as? INStartCallIntent { + callToRecentContact(intent.contacts, intent.callCapability == .videoCall ? .video : .audio) + } else if let intent = intent as? INStartAudioCallIntent { + callToRecentContact(intent.contacts, .audio) + } else if let intent = intent as? INStartVideoCallIntent { + callToRecentContact(intent.contacts, .video) + } + } + + private func callToRecentContact(_ contacts: [INPerson]?, _ mediaType: CallMediaType) { + logger.debug("callToRecentContact") + if let contactId = contacts?.first?.personHandle?.value, + let chat = chatModel.getChat(contactId), + case let .direct(contact) = chat.chatInfo { + logger.debug("callToRecentContact: schedule call") + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + CallController.shared.startCall(contact, mediaType) + } + } + } private func initAuthenticate() { if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 83338804c1..3f338d771d 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -23,7 +23,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse let configuration = CXProviderConfiguration() configuration.supportsVideo = true configuration.supportedHandleTypes = [.generic] - configuration.includesCallsInRecents = false // UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + configuration.includesCallsInRecents = UserDefaults.standard.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) configuration.maximumCallGroups = 1 configuration.maximumCallsPerCallGroup = 1 configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() diff --git a/apps/ios/Shared/Views/UserSettings/CallSettings.swift b/apps/ios/Shared/Views/UserSettings/CallSettings.swift index 9d3f56c710..ca43faab03 100644 --- a/apps/ios/Shared/Views/UserSettings/CallSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/CallSettings.swift @@ -20,26 +20,13 @@ struct CallSettings: View { VStack { List { Section { - Toggle("Connect via relay", isOn: $webrtcPolicyRelay) - - if !CallController.isInChina { - Toggle("Use CallKit", isOn: $callKitEnabled) - -// if allowChangingCallsHistory { -// Toggle("Show calls in phone history", isOn: $callKitCallsInRecents) -// .disabled(!callKitEnabled) -// .onChange(of: callKitCallsInRecents) { value in -// CallController.shared.showInRecents(value) -// } -// } - } - NavigationLink { RTCServers() .navigationTitle("Your ICE servers") } label: { Text("WebRTC ICE servers") } + Toggle("Always use relay", isOn: $webrtcPolicyRelay) } header: { Text("Settings") } footer: { @@ -50,10 +37,29 @@ struct CallSettings: View { } } + if !CallController.isInChina { + Section { + Toggle("Use iOS call interface", isOn: $callKitEnabled) + Toggle("Show calls in phone history", isOn: $callKitCallsInRecents) + .disabled(!callKitEnabled) + .onChange(of: callKitCallsInRecents) { value in + CallController.shared.showInRecents(value) + } + } header: { + Text("Interface") + } footer: { + if callKitEnabled { + Text("You can accept calls from lock screen, without device and app authentication.") + } else { + Text("Authentication is required before the call is connected, but you may miss calls.") + } + } + } + Section("Limitations") { VStack(alignment: .leading, spacing: 8) { textListItem("1.", "Do NOT use SimpleX for emergency calls.") - textListItem("2.", "To prevent the call interruption, enable Do Not Disturb mode.") + textListItem("2.", "Unless you use iOS call interface, enable Do Not Disturb mode to avoid interruptions.") } .font(.callout) .padding(.vertical, 8)